diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 75b0619c..6d649dd5 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -62,7 +62,7 @@ const codexThreadServiceTierKey = "codexThreadServiceTierOverrides"; const codexThreadServiceTierMaxEntries = 120; const codexThreadServiceTierDraftBindWindowMs = 60 * 1000; - const codexServiceTierRequestOverrideVersion = "3"; + const codexServiceTierRequestOverrideVersion = "5"; const codexAppServerModelRequestPatchVersion = "1"; const codexPluginMarketplaceUnlockVersion = "10"; const codexThreadScrollMaxEntries = 120; @@ -81,6 +81,10 @@ clearTimeout(window.__codexProjectMoveChatsSortTimer); window.__codexProjectMoveProjectionTimer = null; window.__codexProjectMoveChatsSortTimer = null; + if (window.__codexPlusBackendHeartbeat) { + clearInterval(window.__codexPlusBackendHeartbeat); + window.__codexPlusBackendHeartbeat = null; + } clearTimeout(window.__codexThreadScrollSaveTimer); window.__codexThreadScrollSaveTimer = null; (window.__codexThreadScrollRestoreTimers || []).forEach((timer) => clearTimeout(timer)); @@ -1091,6 +1095,8 @@ const codexServiceTierFallbackFastValue = "priority"; const codexServiceTierModulePromises = new Map(); const codexServiceTierSupportedFastModels = new Set(["gpt-5.4", "gpt-5.5"]); + const codexServiceTierSelectedModelHintTtlMs = 60 * 1000; + let codexServiceTierSelectedModelHint = { model: "", at: 0 }; const codexThreadServiceTierModes = new Set(["inherit", "standard", "fast"]); const codexServiceTierControlModes = new Set(["inherit", "global-standard", "global-fast", "custom"]); @@ -1118,6 +1124,41 @@ return await codexServiceTierModulePromises.get(namePart); } + function codexDispatcherFromValue(value) { + if (!value) return null; + if (typeof value.dispatchMessage === "function") return value; + if (typeof value !== "function" || typeof value.getInstance !== "function") return null; + try { + const dispatcher = value.getInstance(); + return dispatcher && typeof dispatcher.dispatchMessage === "function" ? dispatcher : null; + } catch (_) { + return null; + } + } + + function codexDispatcherFromModule(module) { + for (const value of Object.values(module || {})) { + const dispatcher = codexDispatcherFromValue(value); + if (dispatcher) return dispatcher; + } + return null; + } + + async function loadCodexAppDispatcher() { + const errors = []; + for (const namePart of ["vscode-api-", "setting-storage-"]) { + try { + const module = await loadCodexAppModule(namePart); + const dispatcher = codexDispatcherFromModule(module); + if (dispatcher) return dispatcher; + errors.push(`${namePart}: no dispatcher in ${Object.keys(module || {}).slice(0, 20).join(",")}`); + } catch (error) { + errors.push(`${namePart}: ${error?.message || String(error)}`); + } + } + throw new Error(`Codex dispatcher unavailable (${errors.join("; ")})`); + } + async function codexSettingStorageModule() { const module = await loadCodexAppModule("setting-storage-"); if (typeof module.n !== "function" || typeof module.s !== "function") { @@ -1156,11 +1197,64 @@ return String(model || "").trim().toLowerCase(); } + function codexServiceTierKnownModelNames() { + return uniqueValues([ + ...Array.from(codexServiceTierSupportedFastModels), + codexModelCatalog.model, + codexModelCatalog.default_model, + ...(Array.isArray(codexModelCatalog.models) ? codexModelCatalog.models : []), + ]); + } + + function escapeCodexServiceTierRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + function codexServiceTierSupportedModelAliasFromText(text) { + const source = String(text || ""); + const aliases = [ + ["gpt-5.5", /(^|[^0-9])(?:gpt[-_\s]*)?5[._-]?5(?![-_\s]*mini)(?=$|[^0-9])/i], + ["gpt-5.4", /(^|[^0-9])(?:gpt[-_\s]*)?5[._-]?4(?![-_\s]*mini)(?=$|[^0-9])/i], + ]; + return aliases.find(([, pattern]) => pattern.test(source))?.[0] || ""; + } + + function codexServiceTierUnsupportedModelAliasFromText(text) { + const source = String(text || ""); + const aliases = [ + ["gpt-5.5-mini", /(^|[^0-9])(?:gpt[-_\s]*)?5[._-]?5[-_\s]*mini(?=$|[^A-Za-z0-9]|extra|high|medium|low)/i], + ["gpt-5.4-mini", /(^|[^0-9])(?:gpt[-_\s]*)?5[._-]?4[-_\s]*mini(?=$|[^A-Za-z0-9]|extra|high|medium|low)/i], + ]; + return aliases.find(([, pattern]) => pattern.test(source))?.[0] || ""; + } + + function codexServiceTierModelFromText(text) { + const source = String(text || "").replace(/\s+/g, " ").trim(); + if (!source || source.length > 120) return ""; + const unsupportedAlias = codexServiceTierUnsupportedModelAliasFromText(source); + if (unsupportedAlias) return unsupportedAlias; + const supportedAlias = codexServiceTierSupportedModelAliasFromText(source); + if (supportedAlias) return supportedAlias; + const lower = source.toLowerCase(); + const knownModels = codexServiceTierKnownModelNames() + .filter(Boolean) + .sort((left, right) => right.length - left.length); + for (const model of knownModels) { + const normalizedModel = normalizeCodexServiceTierModelName(model); + if (!normalizedModel) continue; + if (lower === normalizedModel) return model; + const boundaryPattern = new RegExp(`(^|[^A-Za-z0-9._-])${escapeCodexServiceTierRegExp(model)}([^A-Za-z0-9._-]|$)`, "i"); + if (boundaryPattern.test(source)) return model; + } + const modelMatch = source.match(/\b(?:gpt|o[1-9]|claude|gemini|deepseek|qwen|kimi|moonshot|mistral|llama|sonnet|opus|haiku)[A-Za-z0-9._:-]*\b/i); + return modelMatch ? modelMatch[0] : ""; + } + function codexServiceTierModelFromValue(value, visited = new WeakSet(), depth = 0) { if (typeof value === "string") return value.trim(); if (!value || typeof value !== "object" || visited.has(value) || depth > 3) return ""; visited.add(value); - for (const key of ["model", "modelId", "model_id", "selectedModel", "selected_model", "defaultModel", "default_model"]) { + for (const key of ["selectedModel", "selected_model", "currentModel", "current_model", "activeModel", "active_model", "model", "modelId", "model_id", "modelName", "model_name", "modelSlug", "model_slug", "defaultModel", "default_model"]) { const model = codexServiceTierModelFromValue(value[key], visited, depth + 1); if (model) return model; } @@ -1171,8 +1265,135 @@ return ""; } + function rememberCodexServiceTierSelectedModel(modelName) { + const model = codexServiceTierModelFromText(modelName) || codexServiceTierModelFromValue(modelName); + if (!model) return ""; + codexServiceTierSelectedModelHint = { model, at: Date.now() }; + return model; + } + + function codexServiceTierRecentModelHint() { + const model = codexServiceTierSelectedModelHint.model; + if (!model || Date.now() - codexServiceTierSelectedModelHint.at > codexServiceTierSelectedModelHintTtlMs) return ""; + return model; + } + + function codexServiceTierModelFromElementValue(element) { + if (!element || element.dataset?.codexServiceTierBadge === "true") return ""; + const values = [ + element.textContent, + element.getAttribute?.("title"), + element.getAttribute?.("aria-label"), + element.getAttribute?.("data-model"), + element.getAttribute?.("data-model-id"), + element.getAttribute?.("data-value"), + element.dataset?.model, + element.dataset?.modelId, + element.dataset?.value, + ]; + for (const value of values) { + const model = codexServiceTierModelFromText(value); + if (model) return model; + } + return ""; + } + + function codexServiceTierSelectedModelRoots() { + if (typeof document === "undefined") return []; + let composer = null; + try { + composer = codexServiceTierFindComposerEl(); + } catch (_) { + composer = null; + } + const roots = []; + const addRoot = (root) => { + if (root && !isExtensionUiNode(root) && !roots.includes(root)) roots.push(root); + }; + if (typeof HTMLElement !== "undefined") { + addRoot(composer ? codexServiceTierComposerFooter(composer) : null); + codexServiceTierVisibleComposerFooters().forEach(addRoot); + } + addRoot(composer); + addRoot(document.querySelector?.("[data-testid*='composer']")); + addRoot(document.querySelector?.("[data-testid*='prompt']")); + return roots; + } + + function codexServiceTierSelectedModelFromDom() { + const roots = [ + ...codexServiceTierSelectedModelRoots(), + ]; + for (const root of roots) { + const candidates = [ + root, + ...Array.from(root.querySelectorAll?.("button, [role='button'], [aria-label], [title], [data-model], [data-model-id], [data-value]") || []), + ].filter((node) => node && !isExtensionUiNode(node)); + for (const candidate of candidates.slice(0, 80)) { + const model = codexServiceTierModelFromElementValue(candidate); + if (model) return rememberCodexServiceTierSelectedModel(model); + } + } + return ""; + } + + function codexServiceTierSelectedModelFromReact() { + if (typeof document === "undefined") return ""; + const roots = [ + ...codexServiceTierSelectedModelRoots(), + ...Array.from(document.querySelectorAll?.(".composer-footer, [data-testid*='composer'], [data-testid*='prompt']") || []), + ].filter((root) => root && !isExtensionUiNode(root)); + const visited = new WeakSet(); + for (const root of roots.slice(0, 12)) { + const nodes = [root, ...Array.from(root.querySelectorAll?.("button, [role='button']") || [])]; + for (const node of nodes.slice(0, 80)) { + if (isExtensionUiNode(node)) continue; + for (const key of reactFiberKeys(node)) { + const model = codexServiceTierModelFromObjectGraph(node[key], visited); + if (model) return rememberCodexServiceTierSelectedModel(model); + } + } + } + return ""; + } + + function codexServiceTierModelFromObjectGraph(root, visited = new WeakSet(), maxDepth = 5, maxNodes = 160) { + const stack = [{ value: root, depth: 0 }]; + let scanned = 0; + while (stack.length && scanned < maxNodes) { + const { value, depth } = stack.pop(); + if (!value || visited.has(value) || depth > maxDepth || typeof value !== "object") continue; + visited.add(value); + scanned += 1; + const directModel = codexServiceTierModelFromValue(value, new WeakSet(), 0); + const parsedDirectModel = codexServiceTierModelFromText(directModel); + if (parsedDirectModel) return parsedDirectModel; + if ((typeof Element !== "undefined" && value instanceof Element) || value === window || value === document || value === document.body || value === document.documentElement) continue; + for (const key of Object.keys(value).slice(0, 80)) { + if (key === "ownerDocument" || key === "parentElement" || key === "parentNode" || key === "children" || key === "childNodes") continue; + let child; + try { + child = value[key]; + } catch { + continue; + } + if (typeof child === "string" && /model/i.test(key)) { + const model = codexServiceTierModelFromText(child); + if (model) return model; + } else if (child && typeof child === "object") { + stack.push({ value: child, depth: depth + 1 }); + } + } + } + return ""; + } + function codexServiceTierCurrentModelName() { - return codexServiceTierModelFromValue(codexModelCatalog.model) || codexServiceTierModelFromValue(codexModelCatalog.default_model); + return codexServiceTierSelectedModelFromDom() + || codexServiceTierSelectedModelFromReact() + || codexServiceTierRecentModelHint() + || codexServiceTierModelFromValue(codexModelCatalog.model) + || codexServiceTierModelFromValue(codexModelCatalog.default_model); } function codexServiceTierModelForRequest(params, modelHint = "") { @@ -1387,6 +1608,11 @@ return true; } + function scheduleCodexServiceTierBadgeInstall() { + if (window.__CODEX_PLUS_TEST_SERVICE_TIER__) return; + requestAnimationFrame(() => installCodexServiceTierBadge()); + } + function setCodexServiceTierControlMode(mode) { if (codexPlusBackendStatus.status !== "ok") { showToast("后端未连接,无法切换服务模式", null); @@ -1394,15 +1620,6 @@ return; } const normalizedMode = normalizeCodexServiceTierControlMode(mode); - if (normalizedMode === "global-fast") { - const fastAvailability = codexServiceTierFastAvailability(); - if (!fastAvailability.supported) { - codexServiceTierMaybeLoadModelCatalog(true); - showToast(codexServiceTierFastUnsupportedMessage(fastAvailability.modelName), null); - refreshCodexServiceTierControls(); - return; - } - } const state = readThreadServiceTierState(); state.mode = normalizedMode; if (normalizedMode !== "custom") { @@ -1414,6 +1631,7 @@ } writeThreadServiceTierState(state); refreshCodexServiceTierControls(); + scheduleCodexServiceTierBadgeInstall(); const labels = { inherit: "继承 config.toml", "global-standard": "全局 Standard", @@ -1445,7 +1663,8 @@ const effectiveServiceTier = codexServiceTierValueForControlMode(controlMode, threadMode, defaultMode); const effectiveMode = codexServiceTierEffectiveMode(effectiveServiceTier); const fastAvailability = codexServiceTierFastAvailability(); - const message = effectiveMode === "fast" && !fastAvailability.supported + const threadFastUnsupported = controlMode === "custom" && threadMode === "fast" && !fastAvailability.supported; + const message = threadFastUnsupported ? codexServiceTierFastUnsupportedMessage(fastAvailability.modelName) : serviceTierStatusMessage(controlMode, threadMode, effectiveMode, defaultMode); codexServiceTierState = { @@ -1478,7 +1697,7 @@ `Fast:仅支持 ${codexServiceTierFastModelListLabel()};对支持模型使用 service_tier=\"priority\",官方说明其延迟更低且更一致,但会按更高价格计费;rate limit 与 Standard 共享,流量快速上涨时可能回落到 Standard。`, ].join("\n"); if (effectiveMode === "fast" && !fastAvailability.supported) { - return { tier: "unsupported", label: "不支持", title: `${title}\n${codexServiceTierFastUnsupportedMessage(fastAvailability.modelName)};当前请求会按 Standard 发送。` }; + return { tier: "hidden", label: "", hidden: true, title: `${title}\n${codexServiceTierFastUnsupportedMessage(fastAvailability.modelName)};当前请求会按 Standard 发送。` }; } if (effectiveMode === "fast") return { tier: "fast", label: "fast", title }; return { tier: "standard", label: "standard", title }; @@ -1486,6 +1705,10 @@ function refreshCodexServiceTierBadges() { const state = codexServiceTierBadgeState(); + if (state.hidden) { + removeCodexServiceTierBadges(); + return; + } document.querySelectorAll(`[data-codex-service-tier-badge="true"]`).forEach((node) => { node.dataset.tier = state.tier; node.dataset.disabled = String(!!state.disabled); @@ -1502,11 +1725,13 @@ const backendChecking = codexPlusBackendStatus.status === "checking"; if (featureEnabled && backendConnected) codexServiceTierMaybeLoadModelCatalog(); const fastAvailability = codexServiceTierFastAvailability(); - const fastDisabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading" || !fastAvailability.supported; - const fastTitle = fastAvailability.supported + const controlsDisabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + const globalFastTitle = `全局 Fast:默认使用 service_tier=\"priority\";实际请求仅支持 ${codexServiceTierFastModelListLabel()},其他模型按 Standard 发送。`; + const threadFastDisabled = controlsDisabled || !fastAvailability.supported; + const threadFastTitle = fastAvailability.supported ? "Fast:使用 service_tier=\"priority\"" : codexServiceTierFastUnsupportedMessage(fastAvailability.modelName); - const fastUnsupportedActive = codexServiceTierState.effectiveMode === "fast" && !fastAvailability.supported; + const fastUnsupportedActive = codexServiceTierState.controlMode === "custom" && codexServiceTierState.threadMode === "fast" && !fastAvailability.supported; document.querySelectorAll("[data-codex-service-tier-controls]").forEach((node) => { node.hidden = !featureEnabled; }); @@ -1517,35 +1742,35 @@ : "未启用"; }); document.querySelectorAll("[data-codex-service-tier-inherit]").forEach((button) => { - button.disabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "inherit"); }); document.querySelectorAll("[data-codex-service-tier-standard]").forEach((button) => { - button.disabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "global-standard"); }); document.querySelectorAll("[data-codex-service-tier-fast]").forEach((button) => { - button.disabled = fastDisabled; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "global-fast"); - button.title = fastTitle; + button.title = globalFastTitle; }); document.querySelectorAll("[data-codex-service-tier-custom]").forEach((button) => { - button.disabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "custom"); }); document.querySelectorAll("[data-codex-service-tier-thread-inherit]").forEach((button) => { - button.disabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "custom" && codexServiceTierState.threadMode === "inherit"); button.title = `当前 thread 不单独覆盖,继承自定义默认 ${codexServiceTierState.defaultMode || "inherit"}`; }); document.querySelectorAll("[data-codex-service-tier-thread-standard]").forEach((button) => { - button.disabled = !featureEnabled || !backendConnected || codexServiceTierState.status === "loading"; + button.disabled = controlsDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "custom" && codexServiceTierState.threadMode === "standard"); }); document.querySelectorAll("[data-codex-service-tier-thread-fast]").forEach((button) => { - button.disabled = fastDisabled; + button.disabled = threadFastDisabled; button.dataset.active = String(codexServiceTierState.controlMode === "custom" && codexServiceTierState.threadMode === "fast"); - button.title = fastTitle; + button.title = threadFastTitle; }); refreshCodexServiceTierBadges(); } @@ -1600,6 +1825,7 @@ const threadId = validThreadScrollSessionKey(currentSessionRef().session_id); setCodexThreadServiceTierOverride(threadId, normalizedMode); refreshCodexServiceTierControls(); + scheduleCodexServiceTierBadgeInstall(); const target = threadId ? "当前 thread" : "新 thread 草稿"; showToast(`${target}服务模式:${normalizedMode === "inherit" ? "继承" : normalizedMode}`, null); } @@ -1687,10 +1913,7 @@ function applyCodexServiceTierRequestOverride(method, params, threadIdHint = "") { const override = codexServiceTierOverrideForRequest(method, params, threadIdHint); if (!override) return params; - const nextParams = { ...(params || {}), serviceTier: override.serviceTier }; - if (Object.prototype.hasOwnProperty.call(nextParams, "service_tier") || override.fastBlocked) { - nextParams.service_tier = override.serviceTier; - } + const nextParams = { ...(params || {}), serviceTier: override.serviceTier, service_tier: override.serviceTier }; sendCodexPlusDiagnostic("service_tier_request_override_applied", { method, threadId: override.threadId || "", @@ -1706,6 +1929,11 @@ function codexServiceTierRequestOverride(message) { if (!codexPlusSettings().serviceTierControls) return message; if (!message || typeof message !== "object") return message; + const directMethod = String(message.type || ""); + if (codexServiceTierRequestMethods().has(directMethod)) { + const nextMessage = applyCodexServiceTierRequestOverride(directMethod, message); + return nextMessage === message ? message : nextMessage; + } if (message.type === "send-cli-request-for-host") { const method = String(message.method || ""); const params = applyCodexServiceTierRequestOverride(method, message.params); @@ -1751,20 +1979,15 @@ if (window.__codexServiceTierRequestOverrideInstalled === codexServiceTierRequestOverrideVersion) return; const patch = async () => { try { - const module = await loadCodexAppModule("setting-storage-"); - const dispatcherClass = typeof module.v === "function" && String(module.v).includes("dispatchMessage") ? module.v : null; - const dispatcher = dispatcherClass?.getInstance?.(); + const dispatcher = await loadCodexAppDispatcher(); if (!dispatcher || typeof dispatcher.dispatchMessage !== "function") throw new Error("Codex dispatcher unavailable"); - if (dispatcher.__codexServiceTierOriginalDispatchMessage) { - window.__codexServiceTierRequestOverrideInstalled = codexServiceTierRequestOverrideVersion; - return; - } - dispatcher.__codexServiceTierOriginalDispatchMessage = dispatcher.dispatchMessage.bind(dispatcher); + const originalDispatchMessage = dispatcher.__codexServiceTierOriginalDispatchMessage || dispatcher.dispatchMessage.bind(dispatcher); + dispatcher.__codexServiceTierOriginalDispatchMessage = originalDispatchMessage; dispatcher.dispatchMessage = (type, payload) => { const message = codexServiceTierRequestOverride({ ...(payload || {}), type }); const nextType = message?.type || type; const { type: _type, ...nextPayload } = message || {}; - return dispatcher.__codexServiceTierOriginalDispatchMessage(nextType, nextPayload); + return originalDispatchMessage(nextType, nextPayload); }; window.__codexServiceTierRequestOverrideInstalled = codexServiceTierRequestOverrideVersion; sendCodexPlusDiagnostic("service_tier_dispatcher_patch_installed", {}); @@ -3861,12 +4084,64 @@ let codexModelCatalogLoadedAt = 0; let codexModelCatalogPromise = null; const codexPlusModelListRequestIds = new Set(); + const conversationViewContentClasses = [ + "mx-auto", + "w-full", + "max-w-(--thread-content-max-width)", + "px-toolbar", + "relative", + "flex", + "shrink-0", + "flex-col", + "pb-8", + ]; + const conversationViewComposerClasses = [ + "relative", + "z-10", + "flex", + "flex-col", + "mx-auto", + "w-full", + "max-w-(--thread-content-max-width)", + "px-toolbar", + ]; + const conversationViewState = { + contentEl: null, + composerEl: null, + rafId: 0, + settleFramesLeft: 0, + mo: null, + ro: null, + pollId: 0, + moObserved: false, + observed: new WeakSet(), + elements: new Set(), + }; if (window.__CODEX_PLUS_TEST_SERVICE_TIER__) { window.__codexPlusServiceTierTest = { applyServiceTierOverride: (method, params, threadIdHint = "") => applyCodexServiceTierRequestOverride(method, params, threadIdHint), requestOverride: (message) => codexServiceTierRequestOverride(message), diagnostics: () => [...(window.__codexPlusServiceTierTestDiagnostics || [])], + currentModelName: () => codexServiceTierCurrentModelName(), + fastAvailability: (modelName) => codexServiceTierFastAvailability(modelName), + badgeState: () => codexServiceTierBadgeState(), + installBadge: () => installCodexServiceTierBadge(), + rememberSelectedModel: (modelName) => rememberCodexServiceTierSelectedModel(modelName), + setBackendStatus: (status = {}) => { + codexPlusBackendStatus = { ...codexPlusBackendStatus, ...status }; + }, + setControlMode: (mode) => setCodexServiceTierControlMode(mode), + setThreadMode: (mode) => setCodexThreadServiceTierMode(mode), + threadState: () => readThreadServiceTierState(), + installDispatcherPatch: async () => { + installCodexServiceTierDispatcherPatch(); + for (let attempt = 0; attempt < 30; attempt += 1) { + if (window.__codexServiceTierRequestOverrideInstalled === codexServiceTierRequestOverrideVersion) return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return false; + }, setModelCatalog: (catalog = {}) => { codexModelCatalog = { status: "ok", @@ -5795,9 +6070,7 @@ if (window.__codexUpstreamPendingWorktreeDispatcherPatch === patchVersion) return; const patch = async () => { try { - const module = await loadCodexAppModule("setting-storage-"); - const dispatcherClass = typeof module.v === "function" && String(module.v).includes("dispatchMessage") ? module.v : null; - const dispatcher = dispatcherClass?.getInstance?.(); + const dispatcher = await loadCodexAppDispatcher(); if (!dispatcher || typeof dispatcher.dispatchMessage !== "function") throw new Error("Codex dispatcher unavailable"); if (!dispatcher.__codexUpstreamWorktreeOriginalDispatchMessage) { dispatcher.__codexUpstreamWorktreeOriginalDispatchMessage = dispatcher.dispatchMessage.bind(dispatcher); @@ -6865,40 +7138,6 @@ document.body.appendChild(container); } - const conversationViewContentClasses = [ - "mx-auto", - "w-full", - "max-w-(--thread-content-max-width)", - "px-toolbar", - "relative", - "flex", - "shrink-0", - "flex-col", - "pb-8", - ]; - const conversationViewComposerClasses = [ - "relative", - "z-10", - "flex", - "flex-col", - "mx-auto", - "w-full", - "max-w-(--thread-content-max-width)", - "px-toolbar", - ]; - const conversationViewState = { - contentEl: null, - composerEl: null, - rafId: 0, - settleFramesLeft: 0, - mo: null, - ro: null, - pollId: 0, - moObserved: false, - observed: new WeakSet(), - elements: new Set(), - }; - function conversationViewTokenSet(el) { return new Set(String(el?.className || "").split(/\s+/).filter(Boolean)); } @@ -6968,11 +7207,25 @@ }); } + function codexServiceTierLooksLikeComposerFooter(element) { + if (!(element instanceof HTMLElement)) return false; + if (element.matches?.(".composer-footer")) return true; + const className = String(element.className || ""); + return /(^|\s)_footer_[a-z0-9_-]+/i.test(className) + || (className.includes("grid-cols") && className.includes("items-center") && className.includes("select-none")); + } + + function codexServiceTierComposerFooterElements(root = document) { + const footers = []; + if (codexServiceTierLooksLikeComposerFooter(root)) footers.push(root); + Array.from(root?.querySelectorAll?.(".composer-footer, div[class*='_footer_'], div[class*='grid-cols']") || []) + .filter(codexServiceTierLooksLikeComposerFooter) + .forEach((footer) => footers.push(footer)); + return Array.from(new Set(footers)); + } + function codexServiceTierVisibleComposerFooters(root = document) { - const footers = [ - ...(root?.matches?.(".composer-footer") ? [root] : []), - ...Array.from(root?.querySelectorAll?.(".composer-footer") || []), - ]; + const footers = codexServiceTierComposerFooterElements(root); return footers .filter(codexServiceTierBadgeVisibleElement) .sort((left, right) => { @@ -6989,18 +7242,45 @@ if (providerNames.some((name) => name && text.includes(name))) score += 40; if (/完全访问权限|full access|model|超高|high|sub2api|provider/i.test(text)) score += 20; if (/本地模式|local mode|worktree|branch|codex\//i.test(text)) score -= 30; - if (composer.matches?.(".composer-footer")) score += 4; - if (composer.querySelector?.(".composer-footer")) score += 8; + if (codexServiceTierLooksLikeComposerFooter(composer)) score += 4; + if (codexServiceTierComposerFooterElements(composer).length > 0) score += 8; const buttons = Array.from(composer.querySelectorAll?.("button, [role='button']") || []).filter(codexServiceTierBadgeVisibleElement); if (buttons.some((button) => codexServiceTierLooksLikeProviderButton(button, providerNames))) score += 30; score += Math.min(10, buttons.length); return score; } + function codexServiceTierPromptEditorCandidates() { + return Array.from(document.querySelectorAll(".ProseMirror, [contenteditable='true']")) + .filter(codexServiceTierBadgeVisibleElement) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + return (rightRect.bottom - leftRect.bottom) || (rightRect.width - leftRect.width); + }); + } + + function codexServiceTierComposerFromPromptEditor(editor) { + let node = editor; + for (let depth = 0; node instanceof HTMLElement && depth < 10; depth += 1, node = node.parentElement) { + if (!codexServiceTierBestComposerFooter(node)) continue; + let shell = node; + for (let shellDepth = 0; shell instanceof HTMLElement && shellDepth < 4; shellDepth += 1, shell = shell.parentElement) { + if (codexServiceTierBadgeVisibleElement(shell)) return shell; + } + return node; + } + return null; + } + function codexServiceTierComposerCandidates() { const candidates = new Set(); const threadComposer = conversationViewFindComposerEl(); if (threadComposer && codexServiceTierBadgeVisibleElement(threadComposer)) candidates.add(threadComposer); + codexServiceTierPromptEditorCandidates().forEach((editor) => { + const composer = codexServiceTierComposerFromPromptEditor(editor); + if (composer && codexServiceTierBadgeVisibleElement(composer)) candidates.add(composer); + }); codexServiceTierVisibleComposerFooters().forEach((footer) => { candidates.add(footer); let node = footer.parentElement; @@ -7036,7 +7316,7 @@ } function codexServiceTierComposerFooter(composer) { - if (composer?.matches?.(".composer-footer")) return composer; + if (codexServiceTierLooksLikeComposerFooter(composer)) return composer; return codexServiceTierBestComposerFooter(composer) || codexServiceTierBestComposerFooter() || null; } @@ -7057,7 +7337,10 @@ const anchor = composer ? codexServiceTierBadgeAnchor(composer) : null; if (anchor?.parentElement) return { parent: anchor.parentElement, before: anchor }; const group = composer ? codexServiceTierBadgeFooterGroup(composer) : null; - if (group) return { parent: group, before: group.firstChild }; + if (group) { + const firstVisibleChild = Array.from(group.children || []).find(codexServiceTierBadgeVisibleElement); + return { parent: group, before: firstVisibleChild || group.firstChild }; + } return null; } @@ -7089,6 +7372,10 @@ const composer = codexServiceTierFindComposerEl(); const placement = composer ? codexServiceTierBadgePlacement(composer) : null; const existingBadges = Array.from(document.querySelectorAll(`[data-codex-service-tier-badge="true"]`)); + if (codexServiceTierBadgeState().hidden) { + existingBadges.forEach((badge) => badge.remove()); + return; + } if (!composer || !placement?.parent) { existingBadges.forEach((badge) => badge.remove()); return; @@ -7973,6 +8260,12 @@ '[class*="user-message"]', '[class*="UserMessage"]', ".composer-footer", + ".ProseMirror", + "[contenteditable='true']", + "div[class*='_footer_']", + "div[class*='grid-cols']", + "[data-testid*='composer']", + "[data-testid*='prompt']", selectors.appHeader, selectors.archiveNav, ...(pluginPatchDisabledInRelayMode() ? [] : [selectors.disabledInstallButton]), @@ -7997,6 +8290,12 @@ return nodeSelfOrAncestorMatchesScanRelevance(node) || !!node.querySelector?.(scanRelevantSelector()) || nodeLooksLikeTimelineQuestion(node); } + function isComposerModelMutation(mutation) { + const target = mutation.target?.nodeType === 3 ? mutation.target.parentElement : mutation.target; + if (!target || target.nodeType !== 1 || isExtensionUiNode(target)) return false; + return !!target.closest?.(".composer-footer, [data-testid*='composer'], [data-testid*='prompt'], div[class*='_footer_'], div[class*='grid-cols']"); + } + function isChatContentMutation(mutation) { const target = mutation.target; if (!target?.closest?.('[data-message-author-role], [data-testid="conversation-turn"], main .prose')) return false; @@ -8008,6 +8307,7 @@ if (!mutations) return true; return mutations.some((mutation) => { if (isChatContentMutation(mutation)) return false; + if ((mutation.type === "characterData" || mutation.type === "attributes") && isComposerModelMutation(mutation)) return true; const target = mutation.target; if (isExtensionUiNode(target)) return false; if (target?.nodeType === 1 && nodeSelfOrAncestorMatchesScanRelevance(target)) return true; @@ -8053,5 +8353,11 @@ window.addEventListener("resize", window.__codexPlusResizeHandler); window.__codexSessionDeleteObserver?.disconnect(); window.__codexSessionDeleteObserver = new MutationObserver(scheduleScan); - window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); + window.__codexSessionDeleteObserver.observe(document.body || document.documentElement, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["aria-label", "title", "data-model", "data-model-id", "data-value"], + }); })(); diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 03881a7c..a5e03715 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -83,6 +83,14 @@ fn injection_script_times_out_backend_bridge_calls_and_falls_back_to_helper() { assert!(script.contains("backend_status_bridge_and_http_failed")); } +#[test] +fn injection_script_clears_existing_backend_heartbeat_before_reinstalling() { + let script = assets::injection_script(57321); + + assert!(script.contains("clearInterval(window.__codexPlusBackendHeartbeat)")); + assert!(script.contains("window.__codexPlusBackendHeartbeat = null")); +} + #[test] fn injection_script_explains_plugin_patch_is_unneeded_in_relay_mode() { let script = assets::injection_script(57321); @@ -185,7 +193,9 @@ fn injection_script_keeps_bundled_marketplace_name_for_default_filter() { assert!(script.contains("codexPluginMarketplaceUnlockVersion = \"10\"")); assert!(script.contains("if (name === \"openai-bundled\") return \"\"")); - assert!(!script.contains("if (name === \"openai-bundled\") return \"codex-plus-openai-bundled\"")); + assert!( + !script.contains("if (name === \"openai-bundled\") return \"codex-plus-openai-bundled\"") + ); assert!(script.contains("if (name === \"openai-bundled\" || name === \"codex-plus-openai-bundled\") return \"OpenAI插件1(Codex++)\"")); } @@ -211,9 +221,13 @@ fn injection_script_expands_api_key_plugin_marketplace_requests() { assert!(script.contains("Array.prototype.filter")); assert!(script.contains("codexPluginBuildFlavorFilterPatch")); assert!(script.contains("isCodexPluginBuildFlavorFilter")); - assert!(script.contains("codexPluginOfficialMarketplaceName(plugin?.marketplaceName) && !callback(plugin)")); + assert!(script.contains( + "codexPluginOfficialMarketplaceName(plugin?.marketplaceName) && !callback(plugin)" + )); assert!(script.contains("isCodexPluginMarketplaceHiddenFilter")); - assert!(script.contains("codexPluginOfficialMarketplaceName(marketplace?.name) && !callback(marketplace)")); + assert!(script.contains( + "codexPluginOfficialMarketplaceName(marketplace?.name) && !callback(marketplace)" + )); assert!(script.contains("plugin_marketplace_hidden_filter_bypassed")); assert!(script.contains("method === \"list-plugins\"")); assert!(script.contains("delete next.marketplaceKinds")); @@ -221,10 +235,16 @@ fn injection_script_expands_api_key_plugin_marketplace_requests() { assert!(script.contains("pluginMarketplaceAliasForName")); assert!(script.contains("marketplace.name = alias")); assert!(script.contains("restorePluginMarketplaceName")); - assert!(script.contains("next.remoteMarketplaceName = restorePluginMarketplaceName(next.remoteMarketplaceName)")); + assert!(script.contains( + "next.remoteMarketplaceName = restorePluginMarketplaceName(next.remoteMarketplaceName)" + )); assert!(script.contains("if (name === \"openai-bundled\") return \"\"")); - assert!(script.contains("if (name === \"openai-curated\") return \"codex-plus-openai-curated\"")); - assert!(script.contains("if (name === \"openai-primary-runtime\") return \"codex-plus-openai-primary-runtime\"")); + assert!( + script.contains("if (name === \"openai-curated\") return \"codex-plus-openai-curated\"") + ); + assert!(script.contains( + "if (name === \"openai-primary-runtime\") return \"codex-plus-openai-primary-runtime\"" + )); assert!(script.contains("OpenAI插件1(Codex++)")); assert!(script.contains("OpenAI插件2(Codex++)")); assert!(script.contains("OpenAI插件3(Codex++)")); @@ -388,7 +408,12 @@ fn injection_script_exposes_fast_service_tier_control() { assert!(script.contains("codexServiceTierMaybeLoadModelCatalog")); assert!(script.contains("fastBlocked")); assert!(script.contains("data-tier=\"unsupported\"")); - assert!(script.contains("nextParams.service_tier = override.serviceTier")); + assert!(script.contains("\"vscode-api-\", \"setting-storage-\"")); + assert!(script.contains("codexDispatcherFromModule")); + assert!(script.contains("loadCodexAppDispatcher")); + assert!( + script.contains("serviceTier: override.serviceTier, service_tier: override.serviceTier") + ); assert!(script.contains("serviceTierControls: false")); assert!(script.contains("data-codex-plus-setting=\"serviceTierControls\"")); assert!(script.contains("data-codex-service-tier-controls")); @@ -456,6 +481,11 @@ fn injection_script_applies_fast_service_tier_contract() { cases["unsupportedModel"]["service_tier"], serde_json::Value::Null ); + assert_eq!(cases["unsupportedMiniModel"]["serviceTier"], serde_json::Value::Null); + assert_eq!( + cases["unsupportedMiniModel"]["service_tier"], + serde_json::Value::Null + ); assert_eq!(cases["turnWithoutModel"]["serviceTier"], "priority"); assert_eq!(cases["turnWithoutModelDiagnosticModel"], "gpt-5.4"); @@ -469,9 +499,95 @@ fn injection_script_applies_fast_service_tier_contract() { serde_json::Value::Null ); + assert_eq!(cases["selectedModelName"], "gpt-5.5"); + assert_eq!( + cases["selectedModelFastAvailability"]["modelName"], + "gpt-5.5" + ); + assert_eq!(cases["selectedModelFastAvailability"]["supported"], true); + assert_eq!(cases["selectedModelAliasName"], "gpt-5.5"); + assert_eq!( + cases["selectedModelAliasFastAvailability"]["modelName"], + "gpt-5.5" + ); + assert_eq!( + cases["selectedModelAliasFastAvailability"]["supported"], + true + ); + assert_eq!(cases["selectedModelCompactAliasName"], "gpt-5.5"); + assert_eq!( + cases["selectedModelCompactAliasFastAvailability"]["supported"], + true + ); + assert_eq!(cases["selectedModelMiniAliasName"], "gpt-5.4-mini"); + assert_eq!( + cases["selectedModelMiniAliasFastAvailability"]["supported"], + false + ); + assert_eq!( + cases["selectedModelCompactMiniAliasName"], + "gpt-5.4-mini" + ); + assert_eq!( + cases["selectedModelCompactMiniAliasFastAvailability"]["supported"], + false + ); + + assert_eq!(cases["globalFastUnsupportedState"]["mode"], "global-fast"); + assert_eq!(cases["globalFastUnsupportedState"]["defaultMode"], "fast"); + assert_eq!(cases["globalFastUnsupportedBadge"]["tier"], "hidden"); + assert_eq!(cases["globalFastUnsupportedBadge"]["hidden"], true); + assert_eq!( + cases["threadFastRejectedState"]["entries"]["thread-12345678"]["mode"], + "standard" + ); + assert_eq!(cases["startConversation"]["serviceTier"], "priority"); } +#[test] +fn injection_script_installs_fast_service_tier_dispatcher_patch() { + let cases = run_service_tier_dispatcher_harness(); + + assert_eq!(cases["installed"], true); + assert_eq!(cases["startConversation"]["type"], "start-conversation"); + assert_eq!( + cases["startConversation"]["payload"]["serviceTier"], + "priority" + ); + assert_eq!( + cases["startConversation"]["payload"]["service_tier"], + "priority" + ); + assert_eq!(cases["turnStart"]["type"], "turn/start"); + assert_eq!(cases["turnStart"]["payload"]["serviceTier"], "priority"); + assert_eq!(cases["turnStart"]["payload"]["service_tier"], "priority"); +} + +#[test] +fn injection_script_installs_fast_badge_in_current_composer_dom() { + let cases = run_service_tier_badge_harness(); + + assert_eq!(cases["badgeCount"], 1); + assert_eq!(cases["badgeText"], "fast"); + assert_eq!(cases["badgeTier"], "fast"); + assert_eq!(cases["rightGroupChildren"][0], "fast"); + assert_eq!(cases["rightGroupChildren"][1], "5.5 Extra High"); + assert_eq!(cases["unsupportedBadgeCount"], 0); + assert_eq!(cases["unsupportedRightGroupChildren"][0], "deepseek-v4-pro"); + assert_eq!(cases["unsupportedMiniBadgeCount"], 0); + assert_eq!(cases["unsupportedMiniRightGroupChildren"][0], "5.4-mini"); + assert_eq!(cases["unsupportedCompactMiniBadgeCount"], 0); + assert_eq!( + cases["unsupportedCompactMiniRightGroupChildren"][0], + "5.4-MiniExtra High" + ); + assert_eq!(cases["restoredCompactBadgeCount"], 1); + assert_eq!(cases["restoredCompactBadgeText"], "fast"); + assert_eq!(cases["restoredBadgeCount"], 1); + assert_eq!(cases["restoredBadgeText"], "fast"); +} + fn run_service_tier_contract_harness() -> serde_json::Value { let temp = tempfile::tempdir().expect("temp dir should be created"); let script_path = temp.path().join("renderer-inject.js"); @@ -485,7 +601,8 @@ fn run_service_tier_contract_harness() -> serde_json::Value { const scriptPath = {script_path}; const store = new Map(); store.set("codexPlusSettings", JSON.stringify({{ serviceTierControls: true }})); -function node() {{ +let selectedModelNode = null; +function node(text = "") {{ return {{ appendChild() {{}}, prepend() {{}}, @@ -501,7 +618,7 @@ function node() {{ style: {{}}, children: [], isConnected: true, - textContent: "", + textContent: text, innerHTML: "", }}; }} @@ -512,7 +629,7 @@ globalThis.document = {{ documentElement: node(), body: node(), createElement: () => node(), - querySelector: () => null, + querySelector: (selector) => String(selector || "").includes("composer") ? selectedModelNode : null, querySelectorAll: () => [], addEventListener() {{}}, removeEventListener() {{}}, @@ -544,6 +661,12 @@ const unsupportedModel = api.applyServiceTierOverride("turn/start", {{ service_tier: "priority", }}, "conv-should-not-be-model"); +const unsupportedMiniModel = api.applyServiceTierOverride("turn/start", {{ + threadId: "thread-12345678", + model: "gpt-5.4-mini", + service_tier: "priority", +}}, "conv-should-not-be-model"); + const turnWithoutModel = api.applyServiceTierOverride("turn/start", {{ threadId: "thread-12345678", service_tier: null, @@ -558,6 +681,48 @@ const customInheritUnsupported = api.applyServiceTierOverride("turn/start", {{ service_tier: "priority", }}, ""); +api.setModelCatalog({{ status: "ok", model: "gpt-4.1", default_model: "gpt-4.1", models: ["gpt-4.1", "gpt-5.5"] }}); +selectedModelNode = node("gpt-5.5"); +const selectedModelName = api.currentModelName(); +const selectedModelFastAvailability = api.fastAvailability(); +selectedModelNode = null; + +selectedModelNode = node("5.5 Extra High"); +const selectedModelAliasName = api.currentModelName(); +const selectedModelAliasFastAvailability = api.fastAvailability(); +selectedModelNode = null; + +selectedModelNode = node("Full access5.5Extra High"); +const selectedModelCompactAliasName = api.currentModelName(); +const selectedModelCompactAliasFastAvailability = api.fastAvailability(); +selectedModelNode = null; + +selectedModelNode = node("Full accessfast5.4-MiniExtra High"); +const selectedModelCompactMiniAliasName = api.currentModelName(); +const selectedModelCompactMiniAliasFastAvailability = api.fastAvailability(); +selectedModelNode = null; + +selectedModelNode = node("Full access5.4-mini"); +const selectedModelMiniAliasName = api.currentModelName(); +const selectedModelMiniAliasFastAvailability = api.fastAvailability(); +selectedModelNode = null; + +api.setBackendStatus({{ status: "ok", message: "ok" }}); +api.setServiceTierState({{ status: "ok", serviceTier: null, fastTierValue: "priority" }}); +api.setModelCatalog({{ status: "ok", model: "gpt-4.1", default_model: "gpt-4.1", models: ["gpt-4.1"] }}); +api.setThreadState({{ mode: "global-standard", defaultMode: "standard", entries: {{}} }}); +selectedModelNode = node("deepseek-v4-pro"); +api.setControlMode("global-fast"); +const globalFastUnsupportedState = api.threadState(); +const globalFastUnsupportedBadge = api.badgeState(); +selectedModelNode = null; + +api.setThreadState({{ mode: "custom", defaultMode: "inherit", entries: {{ "thread-12345678": {{ mode: "standard", at: Date.now() }} }} }}); +selectedModelNode = node("deepseek-v4-pro"); +api.setThreadMode("fast"); +const threadFastRejectedState = api.threadState(); +selectedModelNode = null; + api.setModelCatalog({{ status: "ok", model: "gpt-5.5", default_model: "gpt-5.5", models: ["gpt-5.5"] }}); api.setThreadState({{ mode: "global-fast", defaultMode: "fast", entries: {{}} }}); const startConversation = api.requestOverride({{ @@ -569,9 +734,23 @@ const startConversation = api.requestOverride({{ process.stdout.write(JSON.stringify({{ supportedFast, unsupportedModel, + unsupportedMiniModel, turnWithoutModel, turnWithoutModelDiagnosticModel, customInheritUnsupported, + selectedModelName, + selectedModelFastAvailability, + selectedModelAliasName, + selectedModelAliasFastAvailability, + selectedModelCompactAliasName, + selectedModelCompactAliasFastAvailability, + selectedModelMiniAliasName, + selectedModelMiniAliasFastAvailability, + selectedModelCompactMiniAliasName, + selectedModelCompactMiniAliasFastAvailability, + globalFastUnsupportedState, + globalFastUnsupportedBadge, + threadFastRejectedState, startConversation, }})); "#, @@ -594,6 +773,398 @@ process.stdout.write(JSON.stringify({{ serde_json::from_slice(&output.stdout).expect("harness stdout should be JSON") } +fn run_service_tier_badge_harness() -> serde_json::Value { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let script_path = temp.path().join("renderer-inject.js"); + let harness_path = temp.path().join("service-tier-badge-harness.cjs"); + std::fs::write(&script_path, assets::injection_script(57321)) + .expect("injection script should be written"); + let mut harness = std::fs::File::create(&harness_path).expect("harness should be created"); + write!( + harness, + r#" +const scriptPath = {script_path}; +const store = new Map(); +store.set("codexPlusSettings", JSON.stringify({{ serviceTierControls: true }})); + +function datasetKey(name) {{ + return name.replace(/^data-/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +}} + +class FakeElement {{ + constructor(tagName = "div", options = {{}}) {{ + this.tagName = tagName.toUpperCase(); + this.className = options.className || ""; + this.textContent = options.textContent || ""; + this.value = options.value || ""; + this.dataset = {{}}; + this.style = {{}}; + this.children = []; + this.parentElement = null; + this.isConnected = true; + this.attributes = new Map(); + this.rect = options.rect || {{ x: 0, y: 0, width: 10, height: 10 }}; + if (options.role) this.setAttribute("role", options.role); + if (options.ariaLabel) this.setAttribute("aria-label", options.ariaLabel); + if (options.contenteditable) this.setAttribute("contenteditable", options.contenteditable); + }} + + appendChild(child) {{ + return this.insertBefore(child, null); + }} + + insertBefore(child, before) {{ + child.remove?.(); + child.parentElement = this; + child.isConnected = true; + const index = before ? this.children.indexOf(before) : -1; + if (index >= 0) this.children.splice(index, 0, child); + else this.children.push(child); + return child; + }} + + prepend(child) {{ + return this.insertBefore(child, this.children[0] || null); + }} + + remove() {{ + if (!this.parentElement) return; + const siblings = this.parentElement.children; + const index = siblings.indexOf(this); + if (index >= 0) siblings.splice(index, 1); + this.parentElement = null; + this.isConnected = false; + }} + + setAttribute(name, value) {{ + this.attributes.set(name, String(value)); + if (name.startsWith("data-")) this.dataset[datasetKey(name)] = String(value); + }} + + getAttribute(name) {{ + if (name.startsWith("data-")) return this.dataset[datasetKey(name)] || null; + return this.attributes.has(name) ? this.attributes.get(name) : null; + }} + + removeAttribute(name) {{ + this.attributes.delete(name); + if (name.startsWith("data-")) delete this.dataset[datasetKey(name)]; + }} + + addEventListener() {{}} + removeEventListener() {{}} + + getBoundingClientRect() {{ + return {{ + x: this.rect.x, + y: this.rect.y, + left: this.rect.x, + top: this.rect.y, + width: this.rect.width, + height: this.rect.height, + right: this.rect.x + this.rect.width, + bottom: this.rect.y + this.rect.height, + }}; + }} + + matches(selector) {{ + return String(selector || "").split(",").some((part) => this.matchesOne(part.trim())); + }} + + matchesOne(selector) {{ + if (!selector) return false; + if (selector === "div") return this.tagName === "DIV"; + if (selector === "button") return this.tagName === "BUTTON"; + if (selector === ".composer-footer") return this.hasClass("composer-footer"); + if (selector === ".ProseMirror") return this.hasClass("ProseMirror"); + if (selector === "[contenteditable='true']" || selector === '[contenteditable="true"]') return this.getAttribute("contenteditable") === "true"; + if (selector === "[role='button']" || selector === '[role="button"]') return this.getAttribute("role") === "button"; + if (selector === "[data-codex-service-tier-badge=\"true\"]" || selector === "[data-codex-service-tier-badge='true']") return this.dataset.codexServiceTierBadge === "true"; + if (selector === "div[class*='_footer_']") return this.tagName === "DIV" && String(this.className).includes("_footer_"); + if (selector === "div[class*='grid-cols']") return this.tagName === "DIV" && String(this.className).includes("grid-cols"); + return false; + }} + + hasClass(className) {{ + return String(this.className).split(/\s+/).includes(className); + }} + + querySelectorAll(selector) {{ + const result = []; + const visit = (node) => {{ + node.children.forEach((child) => {{ + if (child.matches(selector)) result.push(child); + visit(child); + }}); + }}; + visit(this); + return result; + }} + + querySelector(selector) {{ + return this.querySelectorAll(selector)[0] || null; + }} + + closest(selector) {{ + for (let node = this; node; node = node.parentElement) {{ + if (node.matches(selector)) return node; + }} + return null; + }} +}} + +globalThis.HTMLElement = FakeElement; +globalThis.window = globalThis; +window.__CODEX_PLUS_TEST_SERVICE_TIER__ = true; +globalThis.getComputedStyle = () => ({{ display: "block", visibility: "visible" }}); + +const body = new FakeElement("body", {{ rect: {{ x: 0, y: 0, width: 1296, height: 840 }} }}); +const shell = body.appendChild(new FakeElement("div", {{ className: "relative flex flex-col bg-token-input-background", rect: {{ x: 424, y: 728, width: 736, height: 96 }} }})); +const inner = shell.appendChild(new FakeElement("div", {{ className: "relative z-10 flex min-h-0 flex-1 flex-col", rect: {{ x: 424, y: 728, width: 736, height: 96 }} }})); +const editorWrap = inner.appendChild(new FakeElement("div", {{ className: "mb-1 flex-grow overflow-y-auto px-3", rect: {{ x: 424, y: 740, width: 736, height: 44 }} }})); +editorWrap.appendChild(new FakeElement("div", {{ className: "ProseMirror", contenteditable: "true", rect: {{ x: 436, y: 740, width: 712, height: 44 }} }})); +const footer = inner.appendChild(new FakeElement("div", {{ className: "_footer_1nujl_2 grid grid-cols-[minmax(0,auto)_auto_minmax(0,1fr)] items-center gap-[5px] select-none mb-2 px-2", rect: {{ x: 424, y: 788, width: 736, height: 28 }} }})); +const leftGroup = footer.appendChild(new FakeElement("div", {{ className: "flex min-w-0 items-center gap-[5px]", textContent: "Full access", rect: {{ x: 432, y: 788, width: 149, height: 28 }} }})); +leftGroup.appendChild(new FakeElement("button", {{ ariaLabel: "Add files and more", rect: {{ x: 432, y: 788, width: 28, height: 28 }} }})); +leftGroup.appendChild(new FakeElement("button", {{ textContent: "Full access", rect: {{ x: 465, y: 788, width: 116, height: 28 }} }})); +footer.appendChild(new FakeElement("div", {{ className: "flex items-center", rect: {{ x: 586, y: 802, width: 1, height: 1 }} }})); +const rightGroup = footer.appendChild(new FakeElement("div", {{ className: "flex min-w-0 items-center justify-end gap-2 w-full", textContent: "5.5 Extra High", rect: {{ x: 591, y: 788, width: 561, height: 28 }} }})); +rightGroup.appendChild(new FakeElement("button", {{ textContent: "5.5 Extra High", rect: {{ x: 993, y: 788, width: 123, height: 28 }} }})); +rightGroup.appendChild(new FakeElement("button", {{ ariaLabel: "Send", rect: {{ x: 1124, y: 788, width: 28, height: 28 }} }})); + +globalThis.document = {{ + scripts: [], + documentElement: body, + body, + createElement: (tagName) => new FakeElement(tagName, {{ rect: {{ x: 0, y: 0, width: 54, height: 24 }} }}), + querySelector: (selector) => body.querySelector(selector), + querySelectorAll: (selector) => body.querySelectorAll(selector), + addEventListener() {{}}, + removeEventListener() {{}}, +}}; +globalThis.localStorage = {{ + getItem: (key) => store.has(key) ? store.get(key) : null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), +}}; +globalThis.location = {{ href: "https://codex.test/thread/thread-12345678", pathname: "/thread/thread-12345678", search: "", hash: "" }}; +window.location = globalThis.location; +globalThis.navigator = {{ userAgent: "node-test" }}; +globalThis.performance = {{ getEntriesByType: () => [] }}; + +require(scriptPath); +const api = window.__codexPlusServiceTierTest; +api.setBackendStatus({{ status: "ok", message: "ok" }}); +api.setModelCatalog({{ status: "ok", model: "gpt-5.5", default_model: "gpt-5.5", models: ["gpt-5.5"] }}); +api.setThreadState({{ mode: "global-fast", defaultMode: "fast", entries: {{}} }}); +api.setServiceTierState({{ status: "ok", serviceTier: null, fastTierValue: "priority", controlMode: "global-fast", defaultMode: "fast", threadMode: "inherit", effectiveMode: "fast" }}); +api.installBadge(); + +const supportedBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +const supportedChildren = rightGroup.children.map((child) => child.textContent || child.getAttribute("aria-label") || child.tagName); +rightGroup.textContent = "deepseek-v4-pro"; +rightGroup.children[1].textContent = "deepseek-v4-pro"; +api.setModelCatalog({{ status: "ok", model: "deepseek-v4-pro", default_model: "deepseek-v4-pro", models: ["deepseek-v4-pro"] }}); +api.installBadge(); +const unsupportedBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +const unsupportedChildren = rightGroup.children.map((child) => child.textContent || child.getAttribute("aria-label") || child.tagName); +rightGroup.textContent = "5.5 Extra High"; +rightGroup.children[0].textContent = "5.5 Extra High"; +api.setModelCatalog({{ status: "ok", model: "gpt-5.5", default_model: "gpt-5.5", models: ["gpt-5.5"] }}); +api.installBadge(); +const restoredBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +rightGroup.textContent = "5.4-mini"; +rightGroup.children[1].textContent = "5.4-mini"; +api.setModelCatalog({{ status: "ok", model: "gpt-5.4-mini", default_model: "gpt-5.4-mini", models: ["gpt-5.4-mini"] }}); +api.installBadge(); +const unsupportedMiniBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +const unsupportedMiniChildren = rightGroup.children.map((child) => child.textContent || child.getAttribute("aria-label") || child.tagName); +rightGroup.textContent = "5.4-MiniExtra High"; +rightGroup.children[0].textContent = "5.4-MiniExtra High"; +api.setModelCatalog({{ status: "ok", model: "gpt-5.4-mini", default_model: "gpt-5.4-mini", models: ["gpt-5.4-mini"] }}); +api.installBadge(); +const unsupportedCompactMiniBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +const unsupportedCompactMiniChildren = rightGroup.children.map((child) => child.textContent || child.getAttribute("aria-label") || child.tagName); +rightGroup.textContent = "5.5Extra High"; +rightGroup.children[0].textContent = "5.5Extra High"; +api.setModelCatalog({{ status: "ok", model: "gpt-5.5", default_model: "gpt-5.5", models: ["gpt-5.5"] }}); +api.installBadge(); +const restoredCompactBadges = document.querySelectorAll('[data-codex-service-tier-badge="true"]'); +process.stdout.write(JSON.stringify({{ + badgeCount: supportedBadges.length, + badgeText: supportedBadges[0]?.textContent || "", + badgeTier: supportedBadges[0]?.dataset.tier || "", + rightGroupChildren: supportedChildren, + unsupportedBadgeCount: unsupportedBadges.length, + unsupportedRightGroupChildren: unsupportedChildren, + unsupportedMiniBadgeCount: unsupportedMiniBadges.length, + unsupportedMiniRightGroupChildren: unsupportedMiniChildren, + unsupportedCompactMiniBadgeCount: unsupportedCompactMiniBadges.length, + unsupportedCompactMiniRightGroupChildren: unsupportedCompactMiniChildren, + restoredCompactBadgeCount: restoredCompactBadges.length, + restoredCompactBadgeText: restoredCompactBadges[0]?.textContent || "", + restoredBadgeCount: restoredBadges.length, + restoredBadgeText: restoredBadges[0]?.textContent || "", +}})); +"#, + script_path = serde_json::to_string(&script_path.to_string_lossy().to_string()) + .expect("script path should serialize") + ) + .expect("harness should be written"); + drop(harness); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should run service-tier badge harness"); + assert!( + output.status.success(), + "node harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("harness stdout should be JSON") +} + +fn run_service_tier_dispatcher_harness() -> serde_json::Value { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let script_path = temp.path().join("renderer-inject.js"); + let assets_dir = temp.path().join("assets"); + let vscode_api_path = assets_dir.join("vscode-api-test.js"); + let setting_storage_path = assets_dir.join("setting-storage-test.js"); + let package_path = assets_dir.join("package.json"); + let harness_path = temp.path().join("service-tier-dispatcher-harness.cjs"); + std::fs::write(&script_path, assets::injection_script(57321)) + .expect("injection script should be written"); + std::fs::create_dir_all(&assets_dir).expect("assets dir should be created"); + std::fs::write(&package_path, r#"{"type":"module"}"#).expect("package should be written"); + std::fs::write( + &vscode_api_path, + r#" +export const f = { + messages: [], + dispatchMessage(type, payload) { + this.messages.push({ type, payload }); + }, +}; +"#, + ) + .expect("vscode-api module should be written"); + std::fs::write( + &setting_storage_path, + r#" +export async function n(setting) { return setting.default; } +export async function s() {} +"#, + ) + .expect("setting-storage module should be written"); + + let mut harness = std::fs::File::create(&harness_path).expect("harness should be created"); + write!( + harness, + r#" +(async () => {{ +const {{ pathToFileURL }} = require("url"); +const scriptPath = {script_path}; +const vscodeApiUrl = pathToFileURL({vscode_api_path}).href; +const settingStorageUrl = pathToFileURL({setting_storage_path}).href; +const store = new Map(); +store.set("codexPlusSettings", JSON.stringify({{ serviceTierControls: true }})); +function node(text = "") {{ + return {{ + appendChild() {{}}, + prepend() {{}}, + remove() {{}}, + setAttribute() {{}}, + removeAttribute() {{}}, + addEventListener() {{}}, + querySelector() {{ return null; }}, + querySelectorAll() {{ return []; }}, + closest() {{ return null; }}, + classList: {{ add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }} }}, + dataset: {{}}, + style: {{}}, + children: [], + isConnected: true, + textContent: text, + innerHTML: "", + }}; +}} +globalThis.window = globalThis; +window.__CODEX_PLUS_TEST_SERVICE_TIER__ = true; +globalThis.document = {{ + scripts: [], + documentElement: node(), + body: node(), + createElement: () => node(), + querySelector: () => null, + querySelectorAll: (selector) => selector === "link[href]" + ? [{{ href: vscodeApiUrl }}, {{ href: settingStorageUrl }}] + : [], + addEventListener() {{}}, + removeEventListener() {{}}, +}}; +globalThis.localStorage = {{ + getItem: (key) => store.has(key) ? store.get(key) : null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), +}}; +globalThis.location = {{ href: "https://codex.test/thread/thread-12345678", pathname: "/thread/thread-12345678", search: "", hash: "" }}; +window.location = globalThis.location; +globalThis.navigator = {{ userAgent: "node-test" }}; +globalThis.performance = {{ getEntriesByType: () => [] }}; +require(scriptPath); +const api = window.__codexPlusServiceTierTest; +api.setBackendStatus({{ status: "ok", message: "ok" }}); +api.setServiceTierState({{ status: "ok", serviceTier: null, fastTierValue: "priority" }}); +api.setModelCatalog({{ status: "ok", model: "gpt-5.5", default_model: "gpt-5.5", models: ["gpt-5.5"] }}); +api.setThreadState({{ mode: "global-fast", defaultMode: "fast", entries: {{}} }}); +const vscodeApi = await import(vscodeApiUrl); +const installed = await api.installDispatcherPatch(); +vscodeApi.f.dispatchMessage("start-conversation", {{ + threadId: "thread-12345678", + model: "gpt-5.5", +}}); +const startConversation = vscodeApi.f.messages.at(-1); +vscodeApi.f.dispatchMessage("turn/start", {{ + threadId: "thread-12345678", + model: "gpt-5.5", +}}); +const turnStart = vscodeApi.f.messages.at(-1); +process.stdout.write(JSON.stringify({{ + installed, + startConversation, + turnStart, +}})); +}})().catch((error) => {{ + console.error(error); + process.exit(1); +}}); +"#, + script_path = serde_json::to_string(&script_path.to_string_lossy().to_string()) + .expect("script path should serialize"), + vscode_api_path = serde_json::to_string(&vscode_api_path.to_string_lossy().to_string()) + .expect("vscode api path should serialize"), + setting_storage_path = + serde_json::to_string(&setting_storage_path.to_string_lossy().to_string()) + .expect("setting storage path should serialize"), + ) + .expect("harness should be written"); + drop(harness); + + let output = Command::new("node") + .arg(&harness_path) + .output() + .expect("node should run service-tier dispatcher harness"); + assert!( + output.status.success(), + "node harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("harness stdout should be JSON") +} + #[test] fn injection_script_restores_thread_scroll_positions() { let script = assets::injection_script(57321);