Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/codex-plus-manager/src/session-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ describe("managed aggregate session connection", () => {
codexRemoteSessionProviderPatchEnabled: () => true,
codexRemoteSessionProviderOverrideEnabled: () => false,
applyCodexRemoteSessionProviderOverride: (_method: string, params: any) => params,
prepareCodexPureApiResume: async (_method: string, params: any) => params,
verifyCodexPureApiResumeResult() {},
applyCodexServiceTierRequestOnly: (_method: string, params: any) => params,
loadBackendSettingsState: async () => { calls.push("settings/get"); return true; },
codexAppServerPreparationTimeoutMs: 1500,
Expand Down
464 changes: 464 additions & 0 deletions apps/codex-plus-manager/src/thread-model-preservation.test.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions assets/inject/plugin-market-adapter.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ function fixture(handler, { enabled = true } = {}) {
codexPlusSettings: () => ({ serviceTierControls: false }),
codexRemoteSessionProviderRequestMethod: () => false, codexRemoteSessionProviderOverrideEnabled: () => false,
applyCodexRemoteSessionProviderOverride: (_, params) => params, applyCodexServiceTierRequestOnly: (_, params) => params,
prepareCodexPureApiResume: async (_, params) => params,
verifyCodexPureApiResumeResult: () => {},
refreshCodexThreadModelBeforeTurn: async () => null,
codexThreadModelRequestState: () => ({ threadId: "", model: "" }),
NativeClient,
Expand Down
133 changes: 127 additions & 6 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@
const codexPlusSettingsKey = "codexPlusSettings";
const codexThreadScrollKey = "codexThreadScroll";
const codexServiceTierRequestOverrideVersion = "10";
const codexAppServerModelRequestPatchVersion = "27";
const codexAppServerModelRequestPatchVersion = "32";
const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher";
const codexAppServerClientCaptureAnchor = "async sendRequest(";
const codexRemoteSessionRecoveryVersion = "8";
Expand Down Expand Up @@ -5455,11 +5455,12 @@
if (!params || typeof params !== "object" || Array.isArray(params)) return params;
const profile = codexRemoteSessionActiveProfile();
const pureApi = String(profile?.relayMode || "") === "pureApi";
// Pure API 恢复旧会话时必须覆盖持久化的 openai;官登会话保留原生恢复语义。
if (requestMethod === "thread/resume" && !pureApi) return params;
if (requestMethod === "turn/start" && !pureApi) return params;
const hasModelProvider = Object.prototype.hasOwnProperty.call(params, "modelProvider")
|| Object.prototype.hasOwnProperty.call(params, "model_provider");
const hasModel = Object.prototype.hasOwnProperty.call(params, "model");
// 仅无模型/provider 字段的恢复交给原生;显式 provider 仍需沿用供应商路由。
if (requestMethod === "thread/resume" && (!pureApi || (!hasModel && !hasModelProvider))) return params;
if (requestMethod === "turn/start" && !pureApi) return params;
if (requestMethod === "turn/start" && !hasModelProvider) return params;
const targetProvider = codexRemoteSessionTargetProvider();
if (!targetProvider || targetProvider === "openai") return params;
Expand All @@ -5480,6 +5481,122 @@
return nextParams;
}

async function prepareCodexPureApiResume(method, params, hostId, refreshFailed = false, sendRequest) {
if (hostId !== "local") return params;
if ([params?.hostId, params?.host_id].some((host) => typeof host === "string" && host !== "local")) return params;
if (method === "send-cli-request-for-host" && params?.method) {
if ([params.hostId, params.host_id].some((host) => typeof host === "string" && host !== "local")) return params;
const envelope = () => JSON.stringify([params.hostId, params.host_id, params.method]);
const before = envelope();
const inner = params.params;
const assertEnvelope = () => {
if (envelope() !== before || params.params !== inner) throw new Error("会话主机已变化,请重新打开会话");
};
const nested = await prepareCodexPureApiResume(String(params.method), params.params, hostId, refreshFailed,
sendRequest && ((nestedMethod, nestedParams, options) => {
assertEnvelope();
return sendRequest(method, { ...params, method: nestedMethod, params: nestedParams }, options);
}));
assertEnvelope();
return nested === params.params ? params : { ...params, params: nested };
}
if (method !== "thread/resume" || !params || typeof params !== "object" || Array.isArray(params)
|| codexRemoteSessionActiveProfile()?.relayMode !== "pureApi") {
return params;
}
const specified = (value) => typeof value === "string" && !!value.trim();
const target = codexRemoteSessionTargetProvider();
const requestedProvider = String(params.modelProvider || params.model_provider || "").trim();
// nullable 字段和仅指定供应商不是模型选择;补供应商时必须同时保留保存的模型设置。
if (specified(params.model) || (requestedProvider && requestedProvider !== "openai" && requestedProvider !== target)) return params;
if (specified(params.config?.model)) {
return applyCodexRemoteSessionProviderOverride(method, { ...params, model: params.config.model });
}
if (!target || target === "openai") throw new Error("当前纯 API 供应商不可用");
const requestSignature = () => JSON.stringify([params.threadId,
...["model", "modelProvider", "model_provider"].map((key) => [Object.prototype.hasOwnProperty.call(params, key), params[key]]),
params.config]);
const requestBefore = requestSignature();
const signature = () => {
const profile = codexRemoteSessionActiveProfile();
return JSON.stringify([profile?.id, profile?.relayMode, profile?.configContents, codexRemoteSessionTargetProvider()]);
};
const before = signature();
const assertUnchanged = () => {
if (signature() !== before) throw new Error("供应商配置已变化,请重新打开会话");
if (requestSignature() !== requestBefore) throw new Error("会话设置已变化,请重新打开会话");
};
let metadata;
let fallbackReason = refreshFailed ? "provider_refresh_failed" : "";
if (!fallbackReason) {
try {
metadata = await readCodexAppServerPreparation("/thread-resume-model-metadata",
{ thread_id: params.threadId, host_id: "local" }, codexAppServerPreparationTimeoutMs);
} catch {
fallbackReason = "metadata_unavailable";
}
}
assertUnchanged();
if (metadata?.status !== "ok" || metadata.threadId !== params.threadId
|| typeof metadata.model !== "string" || !metadata.model.trim()
|| !Object.prototype.hasOwnProperty.call(metadata, "reasoningEffort")
|| (metadata.reasoningEffort !== null
&& (typeof metadata.reasoningEffort !== "string" || !metadata.reasoningEffort.trim()))) {
fallbackReason ||= "metadata_unavailable";
}
const config = { ...(params.config || {}) };
delete config.model;
// null 表示原线程未指定档位,沿用原生默认语义;显式请求设置始终优先。
if (!specified(config.model_reasoning_effort)) {
delete config.model_reasoning_effort;
if (!fallbackReason && metadata.reasoningEffort !== null) config.model_reasoning_effort = metadata.reasoningEffort;
}
if (sendRequest) {
// 已加载会话可能忽略连接覆盖;不卸载正在被原生界面使用的实例。
let timer;
let read;
try {
read = await Promise.race([
sendRequest("thread/read", { threadId: params.threadId, includeTurns: false },
{ timeoutMs: codexAppServerPreparationTimeoutMs }),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("读取会话状态超时")), codexAppServerPreparationTimeoutMs); }),
]);
} finally { clearTimeout(timer); }
assertUnchanged();
const status = read?.thread?.status?.type;
if (!["idle", "notLoaded", "active"].includes(status)) throw new Error("无法确认会话连接状态,请重新打开会话");
if (status !== "notLoaded" && read.thread.modelProvider !== target) {
throw new Error("此会话仍使用旧供应商连接,请重新启动 Codex 后重试");
}
}
const next = { ...params, modelProvider: target, config };
if (fallbackReason) {
// 旧线程缺少可靠设置时优先保持可打开,由原生选择默认模型。
delete next.model;
sendCodexPlusDiagnostic("pure_api_resume_model_fallback", { reason: fallbackReason });
} else {
next.model = metadata.model;
}
delete next.model_provider;
(window.__codexPlusPureApiPreparedResumes ||= new WeakSet()).add(next);
return next;
}

function verifyCodexPureApiResumeResult(method, params, result, hostId) {
if (hostId !== "local") return;
if (method === "send-cli-request-for-host" && params?.method) {
return verifyCodexPureApiResumeResult(String(params.method), params.params, result, hostId);
}
if (method !== "thread/resume" || !window.__codexPlusPureApiPreparedResumes?.has(params)) return;
if (result?.modelProvider !== params.modelProvider || (params.model && result?.model !== params.model)
|| (Object.prototype.hasOwnProperty.call(params.config, "model_reasoning_effort")
&& result?.reasoningEffort !== params.config.model_reasoning_effort)) {
throw new Error(params.model
? "会话未采用保存的模型和当前供应商,请重新启动 Codex 后重试"
: "会话未采用当前供应商或指定的推理档位,请重新启动 Codex 后重试");
}
}

function codexManagedSessionConnection() {
const profile = codexRemoteSessionActiveProfile();
const connection = codexPlusBackendSettings.managedSessionConnection;
Expand Down Expand Up @@ -11984,8 +12101,9 @@
const providerParams = providerRefreshFailed
? params
: applyCodexRemoteSessionProviderOverride(requestMethod, params);
const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest);
const nextParams = await prepareCodexManagedSessionConnection(String(method || ""),
providerParams, client.hostId, originalSendRequest);
resumeParams, client.hostId, originalSendRequest);
const modelContextRefresh = await refreshCodexThreadModelBeforeTurn(
client,
originalSendRequest,
Expand All @@ -11998,6 +12116,7 @@
if (managedTurn) return managedTurn.result;
const result = await sendAppServerRequestWithSideChat(client, method, nextParams,
() => originalSendRequest(method, nextParams, options));
verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId);
noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId);
const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result);
if (modelContextRefresh !== false && threadState.threadId && threadState.model
Expand Down Expand Up @@ -12195,8 +12314,9 @@
const providerParams = providerRefreshFailed
? params
: applyCodexRemoteSessionProviderOverride(requestMethod, params);
const resumeParams = await prepareCodexPureApiResume(String(method || ""), providerParams, client.hostId, providerRefreshFailed, originalSendRequest.bind(client));
const nextParams = await prepareCodexManagedSessionConnection(String(method || ""),
providerParams, client.hostId, originalSendRequest.bind(client));
resumeParams, client.hostId, originalSendRequest.bind(client));
const modelContextRefresh = await refreshCodexThreadModelBeforeTurn(
client,
originalSendRequest.bind(client),
Expand All @@ -12209,6 +12329,7 @@
if (managedTurn) return managedTurn.result;
const result = await sendAppServerRequestWithSideChat(client, method, nextParams,
() => originalSendRequest.call(client, method, nextParams, options));
verifyCodexPureApiResumeResult(String(method || ""), nextParams, result, client.hostId);
noteCodexManagedSessionStart(String(method || ""), nextParams, result, client.hostId);
const threadState = codexThreadModelRequestState(String(method || ""), nextParams, result);
if (modelContextRefresh !== false && threadState.threadId && threadState.model
Expand Down
2 changes: 1 addition & 1 deletion assets/inject/renderer-inject/00-prelude.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@
const codexPlusSettingsKey = "codexPlusSettings";
const codexThreadScrollKey = "codexThreadScroll";
const codexServiceTierRequestOverrideVersion = "10";
const codexAppServerModelRequestPatchVersion = "27";
const codexAppServerModelRequestPatchVersion = "32";
const codexAppServerClientCaptureMarker = "AppServerRequestClient is missing a message dispatcher";
const codexAppServerClientCaptureAnchor = "async sendRequest(";
const codexRemoteSessionRecoveryVersion = "8";
Expand Down
Loading
Loading