#7466·cc-switch

[Bug][Codex] 统一会话历史是一次性迁移:迁移后 Codex Desktop 新会话仍写入 openai 桶,导致 ChatGPT 手机 Remote 会话列表不全(同一时刻桌面可见、手机不可见)

Author: Flash0309Created Sep 17, 2026Updated Sep 17, 2026

Self Checks / 自检

  • 我已阅读 README 中的常见问题(FAQ)
  • 我已搜索过已有 Issue(#4710、#6340、#5974、#7362、#6503、#4873、#3225),本报告的根因组合未被覆盖,理由见文末「与已有 Issue 的区别」

Related App / 涉及应用

Codex(Codex Desktop + ChatGPT 手机端远程控制 Remote

Summary / 问题概述

两点,第二点是新影响面:

  1. unifyCodexSessionHistory 是一次性迁移,不追新增。 台账 localMigrations.codexOfficialHistoryUnifyV1 写入后就不再执行。在 unify 已经成功完成、live config.toml 已经是 custom 的前提下,Codex Desktop 之后新建的会话仍然被写成 model_provider = "openai",这些会话永久落在统一桶之外,没有任何机制补标。
  2. 分桶缺陷在手机上表现为「桌面看得到、手机看不到」,而不是「切换 provider 后消失」。 codex app-serverthread/list省略 modelProviders 时会按「当前配置的 model_provider」过滤;手机 Remote 正是不带该参数的客户端,桌面端传的是空数组。于是同一台机器、同一个 app-server、同一时刻,两端列表不一致。现有 issue 全部只描述了桌面/CLI 侧「切换 provider 后历史互相隐藏」,隐含假设是各客户端表现一致。

Environment / 环境信息

  • OS: macOS
  • CC Switch: 3.20.3
  • Codex Desktop(ChatGPT.app): 26.911.61220,内置 codex-cli 0.153.40.155.0-alpha.2.6
  • 手机端: Android ChatGPT App → 远程控制(Remote)连接上述桌面
  • 开关: unifyCodexSessionHistory = trueunifyCodexMigrateExisting = truepreserveCodexOfficialAuthOnSwitch = trueenableLocalProxy = false未开本地路由
  • 当前 Codex Provider: 第三方供应商(model_provider = "custom" 桶)

Steps to Reproduce / 复现步骤

  1. 开启「统一 Codex 会话历史」+「迁移现有官方会话」,切到第三方供应商(使 live config.tomlmodel_provider = "custom"
  2. 确认 unify migration 已完成:~/.cc-switch/settings.json 出现 codexOfficialHistoryUnifyV1
  3. 之后用 Codex Desktop 新建若干会话
  4. 查库:sqlite3 ~/.codex/state_5.sqlite "select id, model_provider from threads where archived = 0" → 第 3 步的新会话是 openai
  5. 手机 Remote 连同一台电脑 → 这些会话不出现;桌面端侧栏同一时刻能看到它们

Evidence / 本机证据(不含凭据、不含会话内容)

A. 迁移确实只跑过一次,且 gate 是通的(与 #6340 不同)

[2026-09-07][14:55:45][INFO][cc_switch_lib::commands::settings] ✓ Codex official history unify migration completed: jsonl_files=32, state_rows=31
json
"localMigrations": {
  "codexOfficialHistoryUnifyV1": {
    "completedAt": "2026-09-07T06:55:45Z",
    "targetProviderId": "custom",
    "migratedJsonlFiles": 32,
    "migratedStateRows": 31
  }
}

09-07 之后(含 09-17 两次 cc-switch 重启)日志里再无任何 unify 记录,也没有 skipped_reason。live ~/.codex/config.toml 第一行至今是 model_provider = "custom" —— 即 codex_config_text_routes_custom 判定通过,inject 没有被显式 openai 值挡住。

B. 迁移完成之后,Codex Desktop 仍持续往 openai 桶写新会话

state_5.sqlite → threads(未归档 33 条)
  model_provider = custom : 29
  model_provider = openai : 4     ← 全部由 Codex Desktop 于 09-10 / 09-11 / 09-11 / 09-17 创建,均在迁移之后

对应 rollout 首行:

json
{"type":"session_meta","payload":{"originator":"Codex Desktop","cli_version":"0.153.4","source":"vscode","model_provider":"openai", ...}}

同期由 CLI / Remote 路径创建的会话则是 "originator":"codex_cli_rs","model_provider":"custom"。也就是说:即使 unify 完全正常工作,Codex Desktop 自己产生的会话仍会漏出统一桶,且一次性台账不会回头补标。

C. 服务端过滤规则实测(决定性证据)

用 Codex Desktop 自带的 codex app-server 二进制另起一个 stdio 实例做只读对照(useStateDbOnly: true 避免触发 JSONL 扫描/修复),对同一个 state_5.sqlitethread/list

请求参数 返回条数 返回的桶
省略 modelProviders(= 手机 Remote 的行为) 4 全部 custom
modelProviders: ["openai"] 4 正好是 B 里漏掉的 4 条
modelProviders: [](= 桌面端的行为) 8 custom + openai
临时 -c model_provider="openai" 后省略该参数 4 返回的反而是那 4 条 openai

即:省略 modelProviders 时的默认值是「当前配置的 model_provider」,而不是「全部 provider」。 协议 schema 里 ThreadListParams.modelProviders 只写了 “when present but empty, includes all providers”,没有写明省略时的这个默认行为,所以很容易被上游调用方(也包括 cc-switch 的文档/设置页文案)忽略。

顺带一条同源的隐藏过滤:省略 sourceKinds 时服务端默认只返回交互式来源(实测 source = exec / subagent 的会话在任何客户端默认列表里都不出现)。这条是 Codex 的设计行为,不影响本 issue 结论,列出来是为了帮「会话看不到」类反馈分流排查。

Actual Behavior / 实际行为

  • 统一历史只在「迁移那一刻」生效一次;之后任何被写成非统一桶的会话永久对该桶的客户端不可见。
  • 因为不同客户端传给 thread/listmodelProviders 不一致,用户看到的是跨端不一致(桌面有、手机没有),比「切换 provider 后列表消失」更難归因,且设置页/日志全程无提示。

Expected Behavior / 期望行为

  1. 增量补标:每次启动、每次切换 provider 时,扫一遍 threads 表与 sessions/**/rollout-*.jsonl 首行 session_meta,把落在非统一桶的会话并入统一桶(幂等、先备份、UI 可预览数量)。或者退一步,至少在设置页提供一个「重新统一历史」按钮。
  2. 可观测:设置页显示当前各 model_provider 桶的未归档会话计数;发现存在桶外会话时给出提示,而不是静默。
  3. 文案:说明「统一会话历史」同时影响 ChatGPT 手机端远程控制(Remote)的会话列表,并说明上述 thread/list 默认按当前 provider 过滤的行为。

与已有 Issue 的区别

  • #6340:根因是 inject_codex_unified_session_bucket 因显式 model_provider = "openai" 早退 → live config 永停在 openai → gate 永不过。本例 gate 通过、迁移已成功完成,问题发生在迁移之后,因此 #7386 Fix 1 不能覆盖本场景
  • #5974:本地路由(代理接管)模式下官方会话写 cc-switch-official 桶。本例 enableLocalProxy = false,未开本地路由。
  • #4710 / #7362 / #6658 / #4873:描述的都是「桌面端/CLI 在切换 provider 后历史消失」,前提假设是各客户端表现一致。本例是同一份 config、同一个 app-server 下桌面与手机不一致,属于同一分桶机制的新影响面,也说明影响不必等到切换 provider 才发生。
  • #7154(open PR):把统一迁移升级到 v2、同时覆盖 openai / cc-switch-official + JSONL + SQLite,方向正确,但仍是一次性补跑,不解决「迁移之后新增会话持续漏」。
  • #3225(最初的功能请求,标题即「会话持续合并」):目前实现不满足「持续」。

Workaround / 临时绕过(供其他用户参考)

切换 provider 或发现手机缺会话后,跑一次下面的脚本把桶外会话并回当前桶。注意只改数据库不够:rollout JSONL 首行 session_meta.model_provider 必须一起改,否则索引重建时会被冲回去。

python
import json, os, re, shutil, sqlite3, time

HOME = os.path.expanduser("~")
cfg = open(os.path.join(HOME, ".codex", "config.toml"), encoding="utf-8").read()
tgt = re.search(r'^\s*model_provider\s*=\s*"([^"]+)"', cfg, re.M)
tgt = tgt.group(1) if tgt else "openai"          # 官方模式不写 provider,回退内置 openai

db_path = os.path.join(HOME, ".codex", "state_5.sqlite")
bak = os.path.join(HOME, ".cc-switch", "backups", "retag-" + time.strftime("%Y%m%d_%H%M%S"))
os.makedirs(os.path.join(bak, "jsonl"), exist_ok=True)
shutil.copy2(db_path, os.path.join(bak, "state_5.sqlite"))

con = sqlite3.connect(db_path)
rows = con.execute("select id, rollout_path from threads where ifnull(model_provider,'') <> ?", (tgt,)).fetchall()
for tid, path in rows:
    if path and os.path.exists(path):
        shutil.copy2(path, os.path.join(bak, "jsonl", os.path.basename(path)))
        lines = open(path, encoding="utf-8").read().splitlines(keepends=True)
        meta = json.loads(lines[0])
        if meta.get("type") == "session_meta" and meta["payload"].get("model_provider") != tgt:
            meta["payload"]["model_provider"] = tgt
            lines[0] = json.dumps(meta, ensure_ascii=False) + "\n"
            open(path + ".tmp", "w", encoding="utf-8").writelines(lines)
            os.replace(path + ".tmp", path)
con.executemany("update threads set model_provider=? where id=?", [(tgt, t) for t, _ in rows])
con.commit(); con.close()
print("retagged:", len(rows), "| backup:", bak)

补充说明

另外建议 cc-switch 在 preserveCodexOfficialAuthOnSwitch + unify 同时开启时,明确告知用户 Codex Desktop 会以哪个桶落盘(实测见 B 组),否则「统一」这个名字与实际行为差距较大,用户很难自己排查到 threads.model_provider 这一层。

以上实验均可在 macOS / Windows 上按「复现步骤」重现。感谢维护者,如果需要我补充特定字段或再跑一轮验证,请在 issue 里说明。