diff --git a/apps/codex-plus-manager/src/session-connection.test.ts b/apps/codex-plus-manager/src/session-connection.test.ts index 2c0569089..e0a8b39a8 100644 --- a/apps/codex-plus-manager/src/session-connection.test.ts +++ b/apps/codex-plus-manager/src/session-connection.test.ts @@ -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, diff --git a/apps/codex-plus-manager/src/thread-model-preservation.test.ts b/apps/codex-plus-manager/src/thread-model-preservation.test.ts new file mode 100644 index 000000000..e6dc3e044 --- /dev/null +++ b/apps/codex-plus-manager/src/thread-model-preservation.test.ts @@ -0,0 +1,464 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL( + "../../../assets/inject/renderer-inject/30-service-tier.js", import.meta.url), "utf8"); +const override = source.slice(0, source.indexOf(" function codexManagedSessionConnection(")); + +function runtime(mode = "pureApi", providerSyncEnabled = false) { + return new Function("codexPlusBackendSettings", "codexModelCatalog", "sendCodexPlusDiagnostic", + `${override}\nreturn applyCodexRemoteSessionProviderOverride;`)( + { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled, + relayProfiles: [{ id: "relay", relayMode: mode, configContents: 'model_provider = "custom"' }] }, + {}, () => {}); +} + +describe("thread model restoration ownership", () => { + it("leaves thread-only resumes intact across distinct saved model and reasoning choices", () => { + const apply = runtime(); + for (const [threadId, model, effort] of [ + ["thread-a", "model-a", "high"], ["thread-b", "model-b", "ultra"], + ["thread-c", "outside-current-catalog", "low"], + ]) { + const params: { threadId: string; model?: string; modelProvider?: string; reasoningEffort?: string } = { threadId }; + const patched = apply("thread/resume", params); + assert.equal(patched, params); + assert.equal(Object.hasOwn(patched, "modelProvider"), false); + assert.deepEqual(params, { threadId }); + const saved = { model, reasoningEffort: effort }; + const native = patched.model || patched.modelProvider || patched.reasoningEffort + ? { model: "global-default", reasoningEffort: "medium" } : saved; + assert.equal(native.model, model); + assert.equal(native.reasoningEffort, effort); + } + }); + + it("preserves field-free and foreign-provider resumes", () => { + const apply = runtime(); + for (const params of [ + { threadId: "thread", reasoningEffort: "high" }, + { threadId: "thread", model: "chosen", modelProvider: "other", reasoningEffort: "ultra" }, + ]) { + assert.equal(apply("thread/resume", params), params); + } + }); + + it("rewrites explicit openai providers with supplier sync enabled or disabled", () => { + for (const sync of [false, true]) { + const apply = runtime("pureApi", sync); + for (const key of ["modelProvider", "model_provider"]) { + const params = { threadId: "old-openai", [key]: "openai" }; + const snapshot = { ...params }; + const patched = apply("thread/resume", params); + assert.equal(patched.modelProvider, "custom"); + assert.equal(Object.hasOwn(patched, "model_provider"), false); + assert.equal(Object.hasOwn(patched, "model"), false); + assert.deepEqual(params, snapshot); + } + } + }); + + it("treats model and provider fields by presence, including null and empty values", () => { + const apply = runtime(); + for (const params of [ + { threadId: "thread", model: null }, + { threadId: "thread", model: "" }, + { threadId: "thread", modelProvider: null }, + { threadId: "thread", model_provider: "" }, + ]) { + assert.equal(apply("thread/resume", params).modelProvider, "custom"); + } + }); + + it("retains provider routing for starts and explicit model switches", () => { + const apply = runtime(); + const params = { threadId: "thread", model: "chosen", reasoningEffort: "high" }; + assert.deepEqual(apply("thread/resume", params), { ...params, modelProvider: "custom" }); + assert.equal(apply("thread/start", {}).modelProvider, "custom"); + assert.equal(apply("thread/resume", params).model, "chosen"); + assert.equal(runtime("official")("thread/resume", params), params); + }); +}); + +const readSource = source.slice(source.indexOf(" async function readCodexAppServerPreparation("), + source.indexOf(" async function loadBackendSettingsState(")); +function snapshotRuntime(read?: (payload: any) => Promise) { + const settings: any = { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, + relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }; + const calls: any[] = []; + const diagnostics: any[] = []; + const create = new Function("codexPlusBackendSettings", "codexModelCatalog", "sendCodexPlusDiagnostic", + "postJson", "codexAppServerPreparationTimeoutMs", "window", `${override}\n${readSource} + return { prepare: prepareCodexPureApiResume, apply: applyCodexRemoteSessionProviderOverride, + verify: verifyCodexPureApiResumeResult };`); + const api = create(settings, {}, (event: string, detail: any) => diagnostics.push({ event, detail }), async (route: string, payload: any) => { + assert.equal(route, "/thread-resume-model-metadata"); + calls.push(payload); + return read ? read(payload) : { status: "ok", threadId: payload.thread_id, model: "saved-model", reasoningEffort: "high" }; + }, 30, {}); + return { ...api, settings, calls, diagnostics }; +} + +describe("pure API persisted model snapshot", () => { + it("restores each saved model and effort with the current provider without changing caller objects", async () => { + const api = snapshotRuntime(async ({ thread_id }) => ({ status: "ok", threadId: thread_id, + model: `saved-${thread_id}`, reasoningEffort: thread_id === "a" ? "ultra" : "low" })); + for (const sync of [false, true]) { + api.settings.providerSyncEnabled = sync; + for (const threadId of ["a", "b"]) { + const params = { threadId, config: { model_context_window: 500000 } }; + const patched = await api.prepare("thread/resume", params, "local"); + assert.equal(patched.model, `saved-${threadId}`); + assert.equal(patched.modelProvider, "custom"); + assert.equal(patched.config.model_reasoning_effort, threadId === "a" ? "ultra" : "low"); + assert.equal(patched.config.model_context_window, 500000); + assert.deepEqual(params, { threadId, config: { model_context_window: 500000 } }); + } + } + }); + + it("preserves explicit reasoning overrides and native semantics for an unspecified saved effort", async () => { + const api = snapshotRuntime(); + const patched = await api.prepare("thread/resume", + { threadId: "a", config: { model_reasoning_effort: "low" } }, "local"); + assert.equal(patched.config.model_reasoning_effort, "low"); + const unspecified = snapshotRuntime(async ({ thread_id }) => + ({ status: "ok", threadId: thread_id, model: "saved-model", reasoningEffort: null })); + const result = await unspecified.prepare("thread/resume", { threadId: "a" }, "local"); + assert.equal(result.model, "saved-model"); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); + }); + + it("never probes remote hosts, official profiles, explicit models or foreign providers", async () => { + const api = snapshotRuntime(async () => { throw new Error("must not read"); }); + for (const host of ["remote", "", undefined]) { + const params = { threadId: "a" }; + assert.equal(await api.prepare("thread/resume", params, host), params); + } + for (const params of [ + { threadId: "a", model: "chosen" }, { threadId: "a", modelProvider: "foreign" }, + { threadId: "a", model_provider: "foreign" }, + { hostId: "remote", method: "thread/resume", params: { threadId: "a" } }, + ]) { + assert.equal(await api.prepare("method" in params ? "send-cli-request-for-host" : "thread/resume", params, "local"), params); + } + const configured = await api.prepare("thread/resume", { threadId: "a", config: { model: "explicit" } }, "local"); + assert.equal(configured.model, "explicit"); + assert.equal(configured.modelProvider, "custom"); + api.settings.relayProfiles[0].relayMode = "official"; + const params = { threadId: "a" }; + assert.equal(await api.prepare("thread/resume", params, "local"), params); + assert.equal(api.calls.length, 0); + }); + + it("restores persisted choices through provider-only and empty optional fields in the actual routing order", async () => { + for (const optional of [ + { model: null }, { model: "" }, { model: " " }, { model: undefined }, + { modelProvider: "openai" }, { model_provider: "openai" }, + { modelProvider: "custom" }, { modelProvider: null }, { model_provider: "" }, + { config: { model: null, model_reasoning_effort: null } }, + { config: { model: "", model_reasoning_effort: "" } }, + ] as Record[]) { + const api = snapshotRuntime(async ({ thread_id }) => + ({ status: "ok", threadId: thread_id, model: "gpt-6.1-terra", reasoningEffort: "xhigh" })); + const params: Record = { threadId: "a", ...optional }; + const routed = api.apply("thread/resume", params); + const result = await api.prepare("thread/resume", routed, "local"); + assert.equal(api.calls.length, 1, JSON.stringify(optional)); + assert.equal(result.model, "gpt-6.1-terra"); + assert.equal(result.modelProvider, "custom"); + assert.equal(result.config.model_reasoning_effort, "xhigh"); + assert.equal(Object.hasOwn(result, "model_provider"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(Object.hasOwn(params, "config") ? params.config : undefined, optional.config); + } + }); + + it("leaves new-thread model fallback with native defaults", async () => { + const api = snapshotRuntime(); + const params = api.apply("thread/start", {}); + const prepared = await api.prepare("thread/start", params, "local"); + assert.equal(prepared.modelProvider, "custom"); + assert.equal(Object.hasOwn(prepared, "model"), false); + assert.equal(api.calls.length, 0); + }); + + it("falls back to the current provider without a model for incomplete, conflicting or failed metadata", async () => { + for (const data of [ + null, { status: "failed" }, { status: "failed", error: "conflicting thread models" }, + { status: "ok", threadId: "other", model: "saved", reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: null, reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: "", reasoningEffort: "high" }, + { status: "ok", threadId: "a", model: "saved" }, + { status: "ok", threadId: "a", model: "saved", reasoningEffort: "" }, + ]) { + const api = snapshotRuntime(async () => data); + const params = { threadId: "a", model: null, model_provider: "openai", + config: { model: "", model_reasoning_effort: "low", model_context_window: 500000 } }; + const original = structuredClone(params); + const result = await api.prepare("thread/resume", params, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result, "model_provider"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(result.config.model_reasoning_effort, "low"); + assert.equal(result.config.model_context_window, 500000); + assert.deepEqual(params, original); + assert.deepEqual(api.diagnostics, [{ event: "pure_api_resume_model_fallback", + detail: { reason: "metadata_unavailable" } }]); + api.verify("thread/resume", result, + { model: "native-default", modelProvider: "custom", reasoningEffort: "low" }, "local"); + assert.throws(() => api.verify("thread/resume", result, + { model: "native-default", modelProvider: "openai", reasoningEffort: "low" }, "local"), /当前供应商/); + } + const failed = snapshotRuntime(async () => { throw new Error("SQLite unavailable"); }); + const result = await failed.prepare("thread/resume", { threadId: "a" }, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); + assert.equal(failed.calls.length, 1); + }); + + it("uses the current provider after refresh failure without reading or injecting saved defaults", async () => { + const api = snapshotRuntime(async () => { throw new Error("must not read"); }); + api.settings.relayProfiles[0].configContents = 'model_provider = "deepseek"'; + api.settings.activeRelayCodexProvider = "stale-provider"; + const result = await api.prepare("thread/resume", + { threadId: "a", model: null, config: { model: null, model_reasoning_effort: null } }, "local", true); + assert.equal(result.modelProvider, "deepseek"); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(Object.hasOwn(result.config, "model"), false); + assert.equal(Object.hasOwn(result.config, "model_reasoning_effort"), false); + assert.equal(api.calls.length, 0); + assert.deepEqual(api.diagnostics, [{ event: "pure_api_resume_model_fallback", + detail: { reason: "provider_refresh_failed" } }]); + const explicit = await api.prepare("thread/resume", + { threadId: "a", config: { model: "chosen" } }, "local", true); + assert.equal(explicit.model, "chosen"); + assert.equal(explicit.modelProvider, "deepseek"); + }); + + it("bounds read waits, falls back and ignores late snapshots", async () => { + let finish!: (value: any) => void; + const api = snapshotRuntime(() => new Promise(resolve => { finish = resolve; })); + const params = { threadId: "a" }; + const result = await api.prepare("thread/resume", params, "local"); + assert.equal(result.modelProvider, "custom"); + assert.equal(Object.hasOwn(result, "model"), false); + finish({ status: "ok", threadId: "a", model: "late", reasoningEffort: "high" }); + await Promise.resolve(); + assert.deepEqual(params, { threadId: "a" }); + assert.equal(Object.hasOwn(result, "model"), false); + assert.equal(api.diagnostics.filter((item: any) => item.event === "pure_api_resume_model_fallback").length, 1); + }); + + it("rejects a provider change while reading the saved model", async () => { + const api = snapshotRuntime(async ({ thread_id }) => { + api.settings.relayProfiles[0].configContents = 'model_provider = "changed"'; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local"), /配置已变化/); + }); + + it("rejects model or config changes during the metadata read", async () => { + for (const key of ["model", "config"]) { + const params: any = { threadId: "a" }; + const api = snapshotRuntime(async ({ thread_id }) => { + params[key] = key === "model" ? "new-choice" : { model_reasoning_effort: "low" }; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + await assert.rejects(api.prepare("thread/resume", params, "local"), /设置已变化/); + } + }); + + it("does not downgrade concurrent setting changes into an availability fallback", async () => { + for (const changed of ["provider", "model"]) { + const params: any = { threadId: "a" }; + const api = snapshotRuntime(async () => { + if (changed === "provider") api.settings.relayProfiles[0].configContents = 'model_provider = "changed"'; + else params.model = "new-choice"; + throw new Error("metadata unavailable"); + }); + await assert.rejects(api.prepare("thread/resume", params, "local"), /已变化/); + assert.equal(api.diagnostics.length, 0); + } + }); + + it("keeps loaded-provider guards on fallback resumes", async () => { + const api = snapshotRuntime(async () => null); + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, + async () => ({ thread: { modelProvider: "openai", status: { type: "idle" } } })), /旧供应商连接/); + assert.equal(api.diagnostics.length, 0); + }); + + it("rejects loaded old-provider sessions without unloading and verifies every restored setting", async () => { + for (const status of ["notLoaded", "idle", "active"]) { + const api = snapshotRuntime(); + const calls: string[] = []; + const rpc = async (method: string) => { + calls.push(method); + return { thread: { id: "a", modelProvider: "openai", status: { type: status } } }; + }; + if (status !== "notLoaded") { + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc), /旧供应商连接/); + assert.deepEqual(calls, ["thread/read"]); + continue; + } + const patched = await api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc); + assert.deepEqual(calls, ["thread/read"]); + api.verify("thread/resume", patched, { model: "saved-model", modelProvider: "custom", reasoningEffort: "high" }, "local"); + for (const result of [ + { model: "saved-model", modelProvider: "openai" }, { model: "global", modelProvider: "custom" }, + { model: "saved-model", modelProvider: "custom", reasoningEffort: "low" }, + ]) { + assert.throws(() => api.verify("thread/resume", patched, result, "local"), /未采用保存的模型/); + } + } + }); + + it("never continues after failed or timed-out state reads", async () => { + const api = snapshotRuntime(); + for (const rpc of [ + async () => { throw new Error("read unavailable"); }, + async () => new Promise(() => {}), + ]) { + await assert.rejects(api.prepare("thread/resume", { threadId: "a" }, "local", false, rpc), + /read unavailable|读取会话状态超时/); + } + }); + + it("prepares nested local resumes without changing the envelope", async () => { + const api = snapshotRuntime(); + const params = { hostId: "local", method: "thread/resume", params: { threadId: "a" }, trace: "keep" }; + const result = await api.prepare("send-cli-request-for-host", params, "local"); + assert.equal(result.params.model, "saved-model"); + assert.equal(result.params.modelProvider, "custom"); + assert.equal(result.trace, "keep"); + assert.deepEqual(params.params, { threadId: "a" }); + }); + + it("rejects a changed host envelope before passing a local snapshot to another host", async () => { + const params = { hostId: "local", method: "thread/resume", params: { threadId: "a" } }; + const api = snapshotRuntime(async ({ thread_id }) => { + params.hostId = "remote"; + return { status: "ok", threadId: thread_id, model: "saved", reasoningEffort: "high" }; + }); + const calls: any[] = []; + await assert.rejects(api.prepare("send-cli-request-for-host", params, "local", false, + async (...args: any[]) => { calls.push(args); }), /主机已变化/); + assert.equal(calls.length, 0); + }); + + it("uses the snapshot in instance, prototype and host-envelope wrappers with exactly one native resume", async () => { + const catalog = readFileSync(new URL("../../../assets/inject/renderer-inject/70-model-catalog.js", import.meta.url), "utf8"); + const instance = catalog.slice(catalog.indexOf(" function patchAppServerModelRequestClient("), catalog.indexOf(" // issue #2177")); + const prototype = catalog.slice(catalog.indexOf(" function installCodexAppServerClientPrototypePatch()"), + catalog.indexOf(" const appServerModelRequestPatchMaxMisses")); + const version = readFileSync(new URL("../../../assets/inject/renderer-inject/00-prelude.js", import.meta.url), "utf8") + .match(/const codexAppServerModelRequestPatchVersion = "([^"]+)"/)?.[1]; + for (const [mode, scenario, optional] of ["instance", "prototype", "envelope"].flatMap(mode => + ["snapshot", "missing", "read-failed", "refresh-failed"].flatMap(scenario => + [{}, { model: null }, { modelProvider: "openai" }, + { config: { model: null, model_reasoning_effort: null } }].map(optional => [mode, scenario, optional] as const)))) { + const calls: any[] = []; + const diagnostics: string[] = []; + let metadataReads = 0; + class Client { + hostId = "local"; + async sendRequest(method: string, params: any) { + calls.push({ method, params }); + const innerMethod = method === "send-cli-request-for-host" ? params.method : method; + const innerParams = method === "send-cli-request-for-host" ? params.params : params; + if (innerMethod === "thread/read") { + return { thread: { id: "a", modelProvider: "openai", status: { type: "notLoaded" } } }; + } + return { model: innerParams.model || "native-default", modelProvider: innerParams.modelProvider, + reasoningEffort: innerParams.config?.model_reasoning_effort, thread: { id: "a" } }; + } + } + const client = new Client(); + const context = vm.createContext({ + window: { __codexPlusAppServerClientClass: Client, __codexSessionDeleteBridge: () => {} }, client, + codexAppServerModelRequestPatchVersion: version, + codexPlusBackendSettings: { relayProfilesEnabled: true, activeRelayId: "relay", providerSyncEnabled: false, + relayProfiles: [{ id: "relay", relayMode: "pureApi", configContents: 'model_provider = "custom"' }] }, + codexModelCatalog: {}, codexPlusBackendSettingsLoaded: true, + codexAppServerPreparationTimeoutMs: 1000, + postJson: async (_route: string, payload: any) => { + metadataReads++; + if (scenario === "missing") return { status: "failed" }; + if (scenario === "read-failed") throw new Error("metadata unavailable"); + return { status: "ok", threadId: payload.thread_id, model: "saved-outside-catalog", reasoningEffort: "ultra" }; + }, + registerNativeHostClient() {}, sendCodexPlusDiagnostic(event: string) { diagnostics.push(event); }, + codexPlusPluginNativeInterceptClient: () => null, + appServerModelRequestMethod: (method: string, params: any) => method === "send-cli-request-for-host" ? params.method : method, + loadBackendSettingsState: async () => scenario !== "refresh-failed", loadCodexModelCatalog: async () => ({}), + prepareCodexManagedSessionConnection: async (_method: string, params: any) => params, + codexManagedSessionConnection: () => null, refreshCodexThreadModelBeforeTurn: async () => null, + sendCodexManagedSessionTurn: async () => null, noteCodexManagedSessionStart() {}, + codexThreadModelRequestState: () => ({}), codexPlusModelUnlockEnabled: () => false, + codexPluginMarketplacePatchEnabled: () => false, codexPlusSettings: () => ({}), + setTimeout, clearTimeout, + }); + vm.runInContext(override + readSource + instance + prototype, context); + vm.runInContext(mode === "prototype" ? "installCodexAppServerClientPrototypePatch()" : "patchAppServerModelRequestClient(client)", context); + const params = { threadId: "a", ...optional }; + await client.sendRequest(mode === "envelope" ? "send-cli-request-for-host" : "thread/resume", + mode === "envelope" ? { hostId: "local", method: "thread/resume", params } : params); + const resumes = calls.filter(call => (mode === "envelope" ? call.params.method : call.method) === "thread/resume"); + assert.equal(resumes.length, 1); + assert.equal(calls.length, 2); + const received = mode === "envelope" ? resumes[0].params.params : resumes[0].params; + assert.equal(received.modelProvider, "custom"); + assert.equal(metadataReads, scenario === "refresh-failed" ? 0 : 1); + if (scenario === "snapshot") { + assert.equal(received.model, "saved-outside-catalog"); + assert.equal(received.config.model_reasoning_effort, "ultra"); + assert.equal(diagnostics.includes("pure_api_resume_model_fallback"), false); + } else { + assert.equal(Object.hasOwn(received, "model"), false); + assert.equal(Object.hasOwn(received.config, "model"), false); + assert.equal(Object.hasOwn(received.config, "model_reasoning_effort"), false); + assert.equal(diagnostics.filter(event => event === "pure_api_resume_model_fallback").length, 1); + } + } + }); +}); + +describe("native default model fallback in pure API filtering", () => { + const catalog = readFileSync(new URL("../../../assets/inject/renderer-inject/70-model-catalog.js", import.meta.url), "utf8"); + const descriptor = catalog.slice(catalog.indexOf(" function codexPlusModelDescriptor("), + catalog.indexOf(" function modelArrayLooksPatchable(")); + const defaults = catalog.slice(catalog.indexOf(" function patchModelDefaults("), + catalog.indexOf(" // 只收窄当前纯 API")); + it("retains upstream replacement for unsupported new-thread defaults without changing the thread model", () => { + const snapshots = new WeakMap(); + const patch = new Function("codexModelCatalog", "codexPlusModelDefaultSnapshots", "modelFilterPolicy", + "codexPlusModelMetadata", "modelReasoningEfforts", `${descriptor}\n${defaults}\nreturn patchModelDefaults;`)( + { model: "gpt-6.1-sol", default_model: "gpt-6.1-sol" }, snapshots, () => "pureApi:local", + () => null, () => []); + const original = { model: "gpt-6.1-terra", defaultReasoningEffort: "xhigh" }; + const host: any = { defaultModel: original, default_model: "gpt-6.1-terra", model: "saved-choice" }; + assert.equal(patch(host, new Set(["gpt-6.1-sol"])), true); + assert.equal(host.defaultModel.model, "gpt-6.1-sol"); + assert.equal(host.default_model, "gpt-6.1-sol"); + assert.equal(host.model, "saved-choice"); + const missing = {}; + patch(missing, new Set(["gpt-6.1-sol"])); + assert.deepEqual(missing, {}); + + const oldPatched = { model: "gpt-6.1-sol", defaultReasoningEffort: "medium" }; + host.defaultModel = oldPatched; + snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); + assert.equal(patch(host, null), true); + assert.equal(host.defaultModel, original); + host.defaultModel = { model: "new-user-choice" }; + const userChoice = host.defaultModel; + snapshots.set(host, { policy: "old", fields: { defaultModel: { original, patched: oldPatched } } }); + patch(host, null); + assert.equal(host.defaultModel, userChoice); + }); +}); diff --git a/assets/inject/plugin-market-adapter.test.cjs b/assets/inject/plugin-market-adapter.test.cjs index 2cc91dec8..64868deeb 100644 --- a/assets/inject/plugin-market-adapter.test.cjs +++ b/assets/inject/plugin-market-adapter.test.cjs @@ -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, diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 71baa6191..3d7aee3d7 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -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"; @@ -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; @@ -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; @@ -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, @@ -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 @@ -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), @@ -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 diff --git a/assets/inject/renderer-inject/00-prelude.js b/assets/inject/renderer-inject/00-prelude.js index 7d2691f55..8c715f73b 100644 --- a/assets/inject/renderer-inject/00-prelude.js +++ b/assets/inject/renderer-inject/00-prelude.js @@ -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"; diff --git a/assets/inject/renderer-inject/30-service-tier.js b/assets/inject/renderer-inject/30-service-tier.js index 9ed9f47ca..e9863f6ba 100644 --- a/assets/inject/renderer-inject/30-service-tier.js +++ b/assets/inject/renderer-inject/30-service-tier.js @@ -90,11 +90,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; @@ -115,6 +116,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; diff --git a/assets/inject/renderer-inject/70-model-catalog.js b/assets/inject/renderer-inject/70-model-catalog.js index df9c4e369..0f211a459 100644 --- a/assets/inject/renderer-inject/70-model-catalog.js +++ b/assets/inject/renderer-inject/70-model-catalog.js @@ -734,8 +734,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, @@ -748,6 +749,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 @@ -945,8 +947,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), @@ -959,6 +962,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 diff --git a/assets/inject/renderer-inject/manifest.json b/assets/inject/renderer-inject/manifest.json index fb2d6c26e..ec7dc2507 100644 --- a/assets/inject/renderer-inject/manifest.json +++ b/assets/inject/renderer-inject/manifest.json @@ -23,7 +23,7 @@ { "name": "30-service-tier.js", "description": "供应商会话适配、请求桥接与原生状态模块定位", - "lines": 900 + "lines": 1017 }, { "name": "31-config-read-deadline.js", @@ -58,7 +58,7 @@ { "name": "70-model-catalog.js", "description": "全局状态、模型目录、模型白名单与解锁", - "lines": 1157 + "lines": 1161 }, { "name": "71-model-picker-groups.js", diff --git a/crates/codex-plus-core/src/codex_sqlite.rs b/crates/codex-plus-core/src/codex_sqlite.rs index f8b1ed554..e3d1d2fe7 100644 --- a/crates/codex-plus-core/src/codex_sqlite.rs +++ b/crates/codex-plus-core/src/codex_sqlite.rs @@ -175,6 +175,74 @@ fn connection_has_column(db: &Connection, table: &str, column: &str) -> bool { .is_ok() } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadResumeModelMetadata { + pub thread_id: String, + pub model: String, + pub reasoning_effort: Option, +} + +/// 一次只读查询取得原生恢复所用的模型设置;不读取或改写会话正文。 +pub fn thread_resume_model_metadata( + home: &Path, + thread_id: &str, +) -> anyhow::Result> { + use rusqlite::OptionalExtension; + anyhow::ensure!( + !thread_id.is_empty() && thread_id.len() <= 128 && thread_id.trim() == thread_id, + "Invalid thread ID" + ); + let mut snapshot = None; + for db_path in codex_session_db_paths_from_home(home) { + if !db_path.exists() { + continue; + } + let db = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + )?; + db.busy_timeout(std::time::Duration::from_millis(50))?; + if !connection_has_table(&db, "threads") { + continue; + } + if !connection_has_column(&db, "threads", "model") + || !connection_has_column(&db, "threads", "reasoning_effort") + { + let contains_thread: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM threads WHERE id = ?1)", [thread_id], |row| row.get(0), + )?; + anyhow::ensure!(!contains_thread, "Saved thread model settings are unavailable"); + continue; + } + let row = db.query_row( + "SELECT model, reasoning_effort FROM threads WHERE id = ?1", + [thread_id], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?)), + ).optional()?; + let Some((model, reasoning_effort)) = row else { + continue; + }; + let model = model.filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Saved thread model is missing"))?; + anyhow::ensure!( + reasoning_effort.as_ref().is_none_or(|value| !value.trim().is_empty()), + "Saved thread reasoning effort is invalid" + ); + let current = ThreadResumeModelMetadata { + thread_id: thread_id.to_string(), + model, + reasoning_effort, + }; + anyhow::ensure!( + snapshot.as_ref().is_none_or(|previous| previous == ¤t), + "Conflicting saved thread model settings" + ); + snapshot = Some(current); + } + Ok(snapshot) +} + /// 读出「未归档目标模式任务」对应 thread 的持久化模型(取 updated_at 最新一条)。 /// 用于重启后把 config.toml 的默认 model 对齐到长线目标任务实际使用的模型, /// 防止恢复任务静默回落到 model_list 第一条(issue #2264)。 diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 81bab257a..08bd8ef79 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -458,6 +458,24 @@ pub async fn handle_bridge_request( .to_string(); ctx.data.recover_remote_control_session(thread_id).await } + "/thread-resume-model-metadata" => { + let request = payload.clone(); + tokio::task::spawn_blocking(move || { + anyhow::ensure!( + request.get("host_id").and_then(Value::as_str) == Some("local") + && request.get("hostId").is_none_or(|host| host.as_str() == Some("local")), + "Thread model metadata is local-only" + ); + let thread_id = request.get("thread_id").and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Thread ID is required"))?; + let snapshot = crate::codex_sqlite::thread_resume_model_metadata( + &crate::codex_home::default_codex_home_dir(), thread_id, + )?.ok_or_else(|| anyhow::anyhow!("Saved thread model settings were not found"))?; + let mut value = serde_json::to_value(snapshot)?; + value["status"] = json!("ok"); + Ok(value) + }).await.unwrap_or_else(|error| Err(anyhow::anyhow!(error.to_string()))) + } "/side-chat/source" | "/side-chat/save" => { let request = payload.clone(); let operation = if path == "/side-chat/source" { diff --git a/crates/codex-plus-core/tests/all/bridge_routes.rs b/crates/codex-plus-core/tests/all/bridge_routes.rs index a72962040..0ffe2528f 100644 --- a/crates/codex-plus-core/tests/all/bridge_routes.rs +++ b/crates/codex-plus-core/tests/all/bridge_routes.rs @@ -15,6 +15,17 @@ use codex_plus_core::status::StatusStore; use codex_plus_core::user_scripts::UserScriptManager; use serde_json::{Value, json}; +#[tokio::test] +async fn thread_resume_metadata_requires_an_explicit_local_host_before_reading_storage() { + for payload in [json!({"thread_id":"a"}), json!({"thread_id":"a","host_id":"remote"}), + json!({"thread_id":"a","host_id":"local","hostId":"remote"})] { + let ctx = test_context(); + let result = handle_bridge_request(ctx, "/thread-resume-model-metadata", payload).await; + assert_eq!(result["status"], "failed"); + assert!(result["message"].as_str().unwrap().contains("local-only")); + } +} + #[tokio::test] async fn bridge_routes_cover_all_current_paths() { let ctx = test_context(); diff --git a/crates/codex-plus-core/tests/all/cdp_bridge.rs b/crates/codex-plus-core/tests/all/cdp_bridge.rs index aaeddc8e8..e07b850cd 100644 --- a/crates/codex-plus-core/tests/all/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/all/cdp_bridge.rs @@ -3345,7 +3345,7 @@ fn injection_script_unlocks_custom_model_catalog() { assert!(script.contains("loadAppServerRequestCandidates")); assert!(script.contains("appServerFallbackAssetUrls")); assert!(script.contains("collectAppServerRequestCandidatesFromModule")); - assert!(script.contains("codexAppServerModelRequestPatchVersion = \"27\"")); + assert!(script.contains("codexAppServerModelRequestPatchVersion = \"32\"")); assert!(script.contains("list-models-for-host")); assert!(script.contains("appServerModelRequestMethod")); @@ -3390,6 +3390,7 @@ fn model_whitelist_never_claims_the_host_default_model() { format!( r#" const codexModelCatalog = {{ + status: "ok", default_model: "supplier-default", model: "supplier-default", provider_name: "Supplier", @@ -3399,7 +3400,8 @@ const codexPlusModelMetadata = () => null; const modelReasoningEfforts = () => []; const applyCodexPlusModelMetadata = () => false; const codexPlusModelNames = () => ["supplier-default", "extra-model"]; -const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode: "official" }}); +let relayMode = "official"; +const codexRemoteSessionActiveProfile = () => ({{ id: "custom", relayMode }}); const codexPlusModelUnlockEnabled = () => true; const codexPlusBackendSettingsLoaded = true; const codexPlusModelCollectionSnapshots = new WeakMap(); @@ -3437,6 +3439,21 @@ const existingContainerDefault = {{ patchModelContainer(existingContainerDefault); if (existingContainerDefault.defaultModel !== hostDefault) process.exit(9); if (existingContainerDefault.model !== hostModel) process.exit(10); + +relayMode = "pureApi"; +const savedDefault = {{ model: "outside-provider-list", defaultReasoningEffort: "xhigh" }}; +const pureApiHost = {{ + models: [{{ model: "native-model", isDefault: true }}], + defaultModel: savedDefault, + default_model: "outside-provider-list", + model: hostModel, +}}; +patchModelContainer(pureApiHost, "local"); +if (pureApiHost.defaultModel.model !== "supplier-default") process.exit(11); +if (pureApiHost.defaultModel.defaultReasoningEffort !== "medium") process.exit(12); +if (pureApiHost.default_model !== "supplier-default") process.exit(13); +if (pureApiHost.model !== hostModel) process.exit(14); +if (!pureApiHost.models.some((item) => item.model === "supplier-default")) process.exit(15); "# ), ) @@ -3597,7 +3614,7 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { assert_eq!(cases["currentStateApi"], true); assert_eq!(cases["appServerParamsUnchanged"], true); assert_eq!(cases["appServerProjectlessParamsUnchanged"], true); - assert_eq!(cases["appServerSentCount"], 7); + assert_eq!(cases["appServerSentCount"], 8); assert_eq!(cases["serviceTierClientPatched"], true); assert_eq!(cases["serviceTierClientSendTier"], "priority"); assert_eq!(cases["serviceTierClientPrewarmTier"], "priority"); @@ -3660,12 +3677,14 @@ fn injection_script_preserves_native_tiers_and_shared_provider_contracts() { assert_eq!(cases["refreshedOpenAiProvider"], "openai"); assert_eq!(cases["refreshedPureApiResumeProvider"], "custom"); assert_eq!(cases["refreshedPureApiResumeModel"], "gpt-5.6-terra"); + assert_eq!(cases["syncDisabledExplicitProvider"], "custom"); + assert_eq!(cases["syncDisabledModelAbsent"], true); assert_eq!(cases["failedRefreshProviderUnchanged"], "openai"); assert_eq!(cases["missingActiveProviderUnchanged"], true); assert_eq!(cases["missingActiveRecoveryUnscheduled"], true); assert_eq!(cases["pureApiThreadStartProvider"], "custom"); assert_eq!(cases["pureApiThreadResumeProvider"], "custom"); - assert_eq!(cases["pureApiNativeResumeProvider"], "custom"); + assert_eq!(cases["pureApiNativeResumePreserved"], true); assert_eq!(cases["pureApiExplicitResumePreserved"], true); assert_eq!(cases["officialResumePreserved"], true); assert_eq!(cases["pureApiTurnStartProvider"], "custom"); @@ -4256,7 +4275,7 @@ window.__codexSessionDeleteBridge = async (path) => {{ if (path === "/settings/get") return {{ launchMode: "patch", enhancementsEnabled: true, - providerSyncEnabled: true, + providerSyncEnabled: false, relayProfilesEnabled: true, activeRelayId: "refreshed-pure-api", activeRelaySessionProvider: "custom", @@ -4272,6 +4291,12 @@ await appServerClient.sendRequest("thread/resume", {{ }}, {{ signal: "pure-api-switch" }}); const refreshedPureApiResumeProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; const refreshedPureApiResumeModel = appServerCalls.at(-1)?.params?.model || ""; +await appServerClient.sendRequest("thread/resume", {{ + threadId: "thread-mobile-openai-sync-disabled", + model_provider: "openai", +}}, {{ signal: "pure-api-sync-disabled" }}); +const syncDisabledExplicitProvider = appServerCalls.at(-1)?.params?.modelProvider || ""; +const syncDisabledModelAbsent = !Object.prototype.hasOwnProperty.call(appServerCalls.at(-1)?.params || {{}}, "model"); delete window.__codexSessionDeleteBridge; api.setBackendSettings({{ relayProfilesEnabled: true, @@ -4311,7 +4336,7 @@ const pureApiThreadResumeParams = {{ }}; const pureApiThreadResumeProvider = api.applyProviderOverride("thread/resume", pureApiThreadResumeParams)?.modelProvider; const pureApiNativeResumeParams = {{ threadId: "thread-mobile-native-resume" }}; -const pureApiNativeResumeProvider = api.applyProviderOverride("thread/resume", pureApiNativeResumeParams)?.modelProvider; +const pureApiNativeResumePreserved = api.applyProviderOverride("thread/resume", pureApiNativeResumeParams) === pureApiNativeResumeParams; const explicitResume = {{ threadId: "thread-other-provider", modelProvider: "other", model: "gpt-custom", reasoningEffort: "high" }}; const pureApiExplicitResumePreserved = api.applyProviderOverride("thread/resume", explicitResume) === explicitResume; const pureApiTurnStartProvider = api.applyProviderOverride("turn/start", {{ @@ -4665,12 +4690,14 @@ process.stdout.write(JSON.stringify({{ refreshedOpenAiProvider, refreshedPureApiResumeProvider, refreshedPureApiResumeModel, + syncDisabledExplicitProvider, + syncDisabledModelAbsent, failedRefreshProviderUnchanged, missingActiveProviderUnchanged, missingActiveRecoveryUnscheduled, pureApiThreadStartProvider, pureApiThreadResumeProvider, - pureApiNativeResumeProvider, + pureApiNativeResumePreserved, pureApiExplicitResumePreserved, officialResumePreserved, pureApiTurnStartProvider, diff --git a/crates/codex-plus-core/tests/all/codex_sqlite.rs b/crates/codex-plus-core/tests/all/codex_sqlite.rs index 9e2319417..0094c6d71 100644 --- a/crates/codex-plus-core/tests/all/codex_sqlite.rs +++ b/crates/codex-plus-core/tests/all/codex_sqlite.rs @@ -4,6 +4,76 @@ use codex_plus_core::codex_sqlite::{ }; use rusqlite::Connection; +#[test] +fn resume_model_metadata_is_read_only_and_reads_one_thread_tuple() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join("state_5.sqlite"); + let db = Connection::open(&path).unwrap(); + db.execute_batch( + "CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT); + INSERT INTO threads VALUES('a','saved-model-a','ultra'),('b','saved-model-b','low'),('c','saved-model-c',NULL);" + ).unwrap(); + drop(db); + let before = std::fs::read(&path).unwrap(); + for (id, model, effort) in [("a", "saved-model-a", Some("ultra")), ("b", "saved-model-b", Some("low")), ("c", "saved-model-c", None)] { + let value = codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), id).unwrap().unwrap(); + assert_eq!(value.model, model); + assert_eq!(value.reasoning_effort.as_deref(), effort); + assert_eq!(value.thread_id, id); + } + assert!(codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "missing").unwrap().is_none()); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn resume_model_metadata_does_not_create_a_missing_database_or_guess_missing_fields() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + let read = |id| codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), id); + assert!(read("a").unwrap().is_none()); + assert!(!home.path().join("state_5.sqlite").exists()); + assert!(read("").is_err()); + let db = Connection::open(home.path().join("state_5.sqlite")).unwrap(); + db.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT); INSERT INTO threads VALUES('a','saved');").unwrap(); + assert!(read("a").is_err()); + db.execute_batch("ALTER TABLE threads ADD COLUMN reasoning_effort TEXT; UPDATE threads SET model='', reasoning_effort='high';").unwrap(); + assert!(read("a").is_err()); + db.execute_batch("UPDATE threads SET model='saved', reasoning_effort='';").unwrap(); + assert!(read("a").is_err()); +} + +#[test] +fn resume_model_metadata_rejects_conflicting_candidate_databases() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("sqlite")).unwrap(); + for (path, model) in [(home.path().join("state_5.sqlite"), "old"), (home.path().join("sqlite/codex-dev.db"), "current")] { + let db = Connection::open(path).unwrap(); + db.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT);").unwrap(); + db.execute("INSERT INTO threads VALUES('a',?1,'high')", [model]).unwrap(); + } + assert!(codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "a").is_err()); +} + +#[test] +fn resume_model_metadata_skips_unrelated_legacy_rows_and_accepts_identical_tuples() { + let _guard = super::common::global_state_lock(); + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("sqlite")).unwrap(); + let legacy = Connection::open(home.path().join("sqlite/codex-dev.db")).unwrap(); + legacy.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY); INSERT INTO threads VALUES('other');").unwrap(); + let modern = Connection::open(home.path().join("state_5.sqlite")).unwrap(); + modern.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model TEXT, reasoning_effort TEXT); + INSERT INTO threads VALUES('a','saved','high');").unwrap(); + let read = || codex_plus_core::codex_sqlite::thread_resume_model_metadata(home.path(), "a"); + assert_eq!(read().unwrap().unwrap().model, "saved"); + legacy.execute_batch("ALTER TABLE threads ADD COLUMN model TEXT; + ALTER TABLE threads ADD COLUMN reasoning_effort TEXT; + INSERT INTO threads VALUES('a','saved','high');").unwrap(); + assert_eq!(read().unwrap().unwrap().reasoning_effort.as_deref(), Some("high")); +} + fn create_threads_table(conn: &Connection) { conn.execute( "CREATE TABLE threads (