Skip to content

feat(sessions): support Qwen Code local sessions - #476

Draft
Dopetaiga wants to merge 9 commits into
zszz3:mainfrom
Dopetaiga:codex/feat-qwen-code-session-source
Draft

feat(sessions): support Qwen Code local sessions#476
Dopetaiga wants to merge 9 commits into
zszz3:mainfrom
Dopetaiga:codex/feat-qwen-code-session-source

Conversation

@Dopetaiga

Copy link
Copy Markdown
Contributor

变更概述

  • 新增 Qwen Code 本地会话来源,支持 V1 和 V2。
  • 按 Qwen Code 当前 JSONL transcript 结构索引 active 与 archive 会话,并使用 parentUuid 重建当前活动链。
  • 支持 QWEN_RUNTIME_DIR、QWEN_HOME 与默认 ~/.qwen 的目录解析优先级。
  • 提取用户可见文本、助手回答、思考与工具轨迹、Token 用量和自定义标题。
  • 对损坏行、粘连 JSON 对象和截断尾部采取容错读取。
  • Qwen Code 原始会话保持只读,不提供删除、恢复、迁移或子代理独立索引。

Closes #475

更新说明检查

  • 本分支已新增且仅新增一个 .release-notes/<branch-slug>.md
  • 更新说明已在标题后显式声明 release-target: both,并覆盖 V1/V2
  • 更新说明包含面向用户的“新增功能”列表
  • 更新说明已移除 MR、分支、CI、发布流程、内部服务、路径及其他不应暴露的实现或敏感信息
  • 已运行 release note 检查(使用 upstream/main 作为正确基线)

验证

  • npm --prefix apps/main-1.0 run typecheck
  • npm --prefix apps/main-2.0 run typecheck
  • V1 Qwen loader 与 format adapter 聚焦测试:21 项通过
  • V2 Qwen loader 与 format adapter 聚焦测试:9 项通过
  • V1 来源注册、环境、存储和批量删除相关回归:通过
  • V2 来源注册、环境、存储、catalog 和批量删除相关回归:通过
  • node scripts/release-notes.mjs check-range upstream/main HEAD
  • git diff --check upstream/main...HEAD

备注:仓库默认 npm run release-note:check 使用陈旧的 fork origin/main,会把上游已有的 124 条 release note 误计入当前范围;显式使用 PR 目标 upstream/main 后检查通过。

@Dopetaiga
Dopetaiga marked this pull request as ready for review August 24, 2026 03:11
@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: ce82e0ec...3429e2c8
覆盖范围: 38/38 diff atoms · 10/10 semantic units

[P1] apps/main-1.0/src/core/session-loader.ts:1971 Qwen JSONL 行被无条件按 }{ 拆分,合法记录静默丢失
qwenJsonlRows(以及 V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:656qwenRows)对每一行先执行 trimmed.split(/\}\s*(?=\{)/u),从不先尝试整行 JSON.parse。只要某行的字符串值内部出现 }{} {(消息或工具输出里包含 f"{a}{b}""{}{}"{{a}}{{b}} 这类常见文本即可触发),该行会被切成两个非法片段并被 catch 静默丢弃;若丢失的记录位于 parentUuid 链中部,链回溯会在缺失 uuid 处 break,导致该会话此前的全部消息一并消失且无任何提示。建议先 JSON.parse(trimmed),仅在整行解析失败时才回退到粘连对象拆分,与 parseJsonlText 的整行优先语义保持一致。

[P2] apps/main-1.0/src/core/session-loader.ts:2049 缓存 token 被重复计入总量
这里把 promptTokenCount 直接作为 inputTokens,同时把 cachedContentTokenCount 作为 cachedInputTokens(V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:704 同样映射并显式相加),而 createTokenUsagetotalTokens 是 input + output + cached + reasoning。Gemini 系 usageMetadata.promptTokenCount 已包含缓存部分,因此任何命中上下文缓存的 Qwen 会话,其 input 与 total token 都会被系统性高估,进而污染会话详情与统计页的 token 与成本估算。建议对齐同仓 normalizeCodexUsage 的做法,取 inputTokens = max(0, promptTokenCount - cachedContentTokenCount)

[P2] apps/main-1.0/src/core/session-loader.ts:2059 会话时间取自链尾,按创建时间排序失真
IndexedSession.timestamp 取的是 leaf(文件中最后一条 user/assistant/tool_result)的时间(V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:708 相同),但该字段在 sessionSortTimestamp 中被用作 “created” 排序轴,而同类 JSONL 单会话来源(openclaw 的 session 元记录、kimi/trae 的首条记录)取的是起始时间。任何多轮 Qwen 会话都会因此把最后活跃时间当作创建时间,按创建时间排序时与其他来源不可比。建议改用 chain 首条含有效 timestamp 的记录,最后活跃时间仍由消息与 mtime 推导。

[P2] apps/main-1.0/src/core/session-loader.ts:2076 archive 副本可覆盖 active 会话
activeIds 只在 active 文件通过 shouldSkipFile 并成功加载后写入,而 archive 分支仅凭 !activeIds.has(rawId) 判断重复(V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:717 相同)。增量索引轮次中若 active 文件自上次索引未变化而被跳过,同名 archive 副本又是新增或已更新,则 activeIds 为空、archive 会话被产出;两者 sessionKey 同为 qwen:<basename>,索引按 key upsert,已索引的 active 会话内容与 filePath 会被 archive 旧快照覆盖,直到 active 文件再次变更才恢复。建议 archive 去重不依赖本轮是否加载过 active,例如先枚举 active 目录的文件名集合,或在 skip 判断之前登记 rawId。

@Dopetaiga
Dopetaiga force-pushed the codex/feat-qwen-code-session-source branch from 3429e2c to 0e8d0e3 Compare August 24, 2026 04:51
@Dopetaiga

Copy link
Copy Markdown
Contributor Author

已根据反馈完成修复,并将分支重放到最新 upstream/main:

@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: abd5105f...0e8d0e3a
覆盖范围: 38/38 diff atoms · 10/10 semantic units

[P1] apps/main-1.0/src/core/session-loader.ts:2064 逐条 token 事件被丢弃,Qwen 在统计页恒为 0 token
两个 app 的 Qwen loader 都完整构造了 TokenUsageEvent[](V1 usageEvents,V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:710tokenEvents),但返回的 LoadedSession 只带 session/messages/traceEvents,未返回 tokenEvents,因此 token_events 表不会写入任何 Qwen 行。只要会话含 usageMetadata(正常情况)即触发:V2 的来源统计只 join agent_recall.token_eventsapps/main-2.0/src/core/postgres/session-stats-repository.ts:215),Qwen Code 统计恒为 0;V1 src/core/store/sessions.ts:1548 仅在 range.since === nulltoken_events 全局为空时才回退会话表,因此库中一旦存在其他来源事件,Qwen 行同样为 0;V2 还会经 src/core/turns/derive-turns.ts:338 把每轮用量算成 0,与会话总量自相矛盾。同仓 pi/zcode/opencode/claude/codex loader 均返回该数组。修复方向:两处 return 补上 tokenEvents(V1 传 usageEvents)。

[P2] apps/main-1.0/src/core/format-adapters.ts:370 qwenAdapter 没有生产消费者,且与 loader 解析语义分叉
新增的 qwenAdapter(V2 apps/main-2.0/src/core/format-adapters.ts:368 同)只被两个新单测调用:getAdapter 的全部生产调用点都传字面量 format(codex/kimi/openclaw/cursor),Qwen 消息完全由 loader 自行提取,没有任何路径会以 qwen 取到它。这既违反 AGENTS.md:48「不新增没有当前消费者的兼容路径,也不为把实现细节暴露给测试而新增导出」,也留下语义分叉:该 adapter 不做 isMeaningfulUserMessage 过滤、不做 parentUuid 链过滤,将来一旦接上就会把已回退分支和内部 prompt 当成可见消息。修复方向:删除该 adapter 与两处 dispatch(含对应测试),或让 loader 改用 sourceMessages(chain, "qwen") 使解析路径唯一。

[P2] apps/main-1.0/src/core/session-loader.ts:65 V1 的 QWEN_RUNTIME_DIR/QWEN_HOME 优先级在生产路径不生效
resolveQwenCodeRoot 只在 options.homeDir === undefined 时读取环境变量,而 V1 索引入口固定传入 homeDirapps/main-1.0/src/main/index.ts:1297),V2 的 runIndexSync 则不传(apps/main-2.0/src/main/index.ts:1771)。因此用户迁移 Qwen Code 运行目录后,V2 能索引到,V1 只看 ~/.qwen 并静默索引不到任何会话,与本 PR 声明的「支持 QWEN_RUNTIME_DIR、QWEN_HOME 与默认 ~/.qwen 的目录解析优先级」以及 release-target: both 不一致;新增 V1 用例之所以通过,是因为它省略了 homeDir,覆盖的是生产不会走的分支。修复方向:把环境变量解析移到 options.homeDir 判断之前,使两个 app 行为一致,或删除该分支并同步 PR 与 release note 描述。

@Dopetaiga
Dopetaiga force-pushed the codex/feat-qwen-code-session-source branch from 0e8d0e3 to 14b4099 Compare August 24, 2026 07:39
@Dopetaiga

Copy link
Copy Markdown
Contributor Author

第二轮反馈已修复并同步到最新 upstream/main,提交 14b4099:V1/V2 Qwen loader 现返回逐条 tokenEvents;active parent chain 统一经 qwen adapter 解析,保留 displayText、thought 过滤和文本 fallback,删除重复解析路径;QWEN_RUNTIME_DIR > QWEN_HOME > homeDir/.qwen 在显式 homeDir 下也生效。补充了事件明细、显式 homeDir 环境变量优先级、文本 fallback 与推理不泄露测试。验证结果:V1 聚焦测试 24/24、V2 12/12、双端 typecheck、V2 dead-code(591 modules)、release-note range、git diff check、独立只读验收均通过。

@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: b31918ac...14b4099a
覆盖范围: 65/65 diff atoms · 10/10 semantic units

[P2] apps/main-2.0/src/core/session-loaders/alternative-sources.ts:690 V2 Qwen loader 绕过时间戳归一化,与 V1 行为分叉
V1 的 Qwen loader 统一走归一化助手:trace 事件用 timestampString(row.timestamp)apps/main-1.0/src/core/session-loader.ts:1994:1997),token 事件与会话创建时间用 timestampMs(row.timestamp):2033:2039),两者都接受数字 timestamp 并按秒/毫秒换算。V2 对应位置改用 Date.parse(stringField(row, "timestamp")) || 0:690:694)与 stringField(row, "timestamp"):672:675),而 stringField 对非字符串一律返回 ""。因此当 Qwen 记录以数字 epoch 写 timestamp 时(本 PR 的 V1 loader 与共享 qwenAdapter 依赖的 timestampFromRaw 都显式支持该形态),V2 会把全部 Qwen token 事件的 timestamp 记为 0、trace 事件时间记为空串、会话创建时间回退到文件 mtime,用量趋势与统计分桶随之错位,而 V1 结果正确;同一文件已在 :110:116 定义 timestampString/timestampMs,kimi、opencode、cursor、qoder 等既有 loader 均在使用,只有新增的 Qwen 分支绕开。两个新增测试只喂 ISO 字符串,CI 覆盖不到该分叉,也不满足 AGENTS.md 中「若 V1/V2 会话行为有意不同,需说明用户可见原因并用测试覆盖」的要求。修复方向:V2 这四处改用与 V1 相同的 timestampMs/timestampString,并补一条数字 timestamp 的 fixture。

@Dopetaiga

Copy link
Copy Markdown
Contributor Author

第三轮反馈已修复,提交 ef2246a。V2 Qwen 的 function/thought trace、独立 tool_result trace、token event 与 active chain 创建时间现统一复用 timestampString/timestampMs,与 V1 归一化语义一致。新增 synthetic fixture 同时覆盖数字秒 epoch 与数字毫秒 epoch,并断言 session、message、token、functionCall trace、tool_result trace。已通过 V2 Qwen 6/6、typecheck、dead-code 591 modules、release-note range、git diff check 与独立只读验收。

@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: b31918ac...ef2246af
覆盖范围: 64/64 diff atoms · 10/10 semantic units

[P2] apps/main-1.0/src/core/session-loader.ts:2020 parentUuid 链断裂时静默丢弃断点之前的全部历史
qwenJsonlRows 按本 PR 的容错策略丢弃解析失败的行,只要被丢弃的行不是文件末行,其 uuid 就不会进入 records,而后续记录仍通过 parentUuid 指向它;此时 records.get(parent) 返回 undefined,回溯循环直接结束,chain 只保留缺口之后的片段,于是该会话的 messages、tokenEvents、traceEvents、firstQuestion 以及作为创建时间的 chainTimestamp 全部按尾部片段计算,界面上表现为“只剩最后一段”且没有任何提示;V2 的 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:686 同源。同仓库对同类 uuid/parentUuid 链的既有约定是异常即整体回退而非截断(apps/main-1.0/src/core/session-loader.ts:349-357claudeVisibleConversationRows 在父节点缺失或 uuid 重复时 return rowsapps/main-1.0/src/core/session-loader.ts:1548-1552piActiveRows 返回 null 由调用方兜底)。建议对齐该约定:检测到父节点缺失或 uuid 重复时回退为文件顺序的全量记录,或桥接到上一个仍连通的片段,避免静默丢史。

@Dopetaiga

Copy link
Copy Markdown
Contributor Author

已按反馈修复 Qwen Code 父链损坏时的历史静默截断(commit 23bc822):\n\n- 正常 parentUuid 链仍严格只读取活动分支,rewound 分支继续排除。\n- 若链中出现缺失 parent、重复 UUID、cycle、非法 parent 类型或活动节点缺少 UUID,则降级为按文件顺序读取全部可解析记录,避免丢失断点之前的历史;损坏场景可能包含 rewound 分支,这是明确的保全优先取舍。\n- 无 UUID 的普通 metadata 不会误触发降级。\n- 同一 recovered row set 统一驱动 messages、token events/totals、trace events 和会话时间。\n- V1/V2 均新增断链、重复 UUID 与健康链 metadata 回归覆盖。\n\n本地验证:V1 Qwen 7/7,V2 Qwen 8/8;两套 typecheck 通过,V2 dead-code 591 modules 通过;release-note range check 与 git diff --check 通过。

@Dopetaiga
Dopetaiga marked this pull request as draft August 24, 2026 11:56
@Dopetaiga
Dopetaiga force-pushed the codex/feat-qwen-code-session-source branch from 23bc822 to efb5471 Compare August 24, 2026 12:17
@Dopetaiga
Dopetaiga marked this pull request as ready for review August 24, 2026 12:26
@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: 7322a24e...efb54716
覆盖范围: 62/62 diff atoms · 12/12 semantic units

[P1] apps/main-1.0/src/core/session-loader.ts:2063 Qwen token 事件未按 dedupeKey 归并,重复 uuid 会撞 token_events 主键并让整个会话索引失败
本 PR 把 uuid 重复当作可容忍输入:loadQwenCodeSessionFile 检测到重复 uuid 后置 chainInvalid 并回退到全量 parsedRowssession-loader.ts:2052),随后对每条带 usageMetadata 的记录直接 usageEvents.push(tokenEvent(..., stringField(row, "uuid"), ...)),同一 uuid 便产生两条相同 dedupeKey 的事件(本 PR 自带用例 session-loader-qwen.test.ts 即断言 ["dup", "dup"])。但 token_events 主键是 (session_key, dedupe_key)apps/main-1.0/src/core/store/schema.ts:116apps/main-2.0/src/core/postgres/schema.ts:164),写入为无 ON CONFLICT 的纯 INSERT(apps/main-1.0/src/core/store/sessions.ts:401apps/main-2.0/src/core/postgres/session-repository.ts:867),而 upsertIndexedSession 只过滤空 dedupeKey 与零 total、不做归并(sessions.ts:249),于是唯一键冲突导致事务回滚,该会话被 indexer.ts 的 per-session catch 记为索引失败并从列表中消失,用户看不到原因;V2 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:708 同源,且连空 dedupeKey 也未过滤。仓库内其余产生 token 事件的 loader 一律经 putTokenEvent 按 dedupeKey 归并,建议同样改用 putTokenEvent(保留 totalTokens 较大者)并丢弃无 uuid 的 usage 行。

[P2] apps/main-1.0/src/core/session-loader.ts:2020 qwen.tool_result 用记录 uuid 充当 callId,工具调用永远无法闭合
functionCall part 产出的 tool_call 事件 callId 取 functionCall.id,而 type: "tool_result" 行合成的结果事件 callId 取 stringField(row, "uuid"),两者不可能相等(本 PR fixture 即 id: "c1"uuid: "t")。V2 的 span 配对完全依赖 callId:apps/main-2.0/src/core/turns/derive-turns.ts:415 只有在 calls.get(callId) 命中时才把 tool_call span 从 running 改为 completed 并挂上输出,因此 Qwen 的每个工具调用在 turn/trace 视图中长期停留在 running,工具输出另起一条孤立 span;apps/main-2.0/src/core/session-loaders/alternative-sources.ts:693 同源,同仓库既有 loader 均传真实调用 id(apps/main-2.0/src/core/session-loader.ts:398tool_use_id:543call_id)。建议结果事件的 callId 取 toolCallResult/functionResponse 中的调用 id,缺失时回退到父 assistant 记录的 functionCall.id

@Dopetaiga
Dopetaiga marked this pull request as draft August 24, 2026 13:51
@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: 7322a24e...63ab7c50
覆盖范围: 68/68 diff atoms · 16/16 semantic units

[P2] apps/main-1.0/src/core/session-loader.ts:2057 Qwen 思考轨迹被归类为工具事件
qwenTraceEventsthought: true 的 part 写入 eventType: "qwen.thought"(V2 同 apps/main-2.0/src/core/session-loaders/alternative-sources.ts:730),但两个 app 的 core/trace-presentation.ts:41 只把 codex.reasoning_summaryagent_reasoning 归入 reasoning,未登记的类型落到默认 { category: "tool" }。因此只要 Qwen 会话包含思考内容,推理事件在会话详情里就受“显示工具”开关控制(detail-panel.tsx:103),V2 还会被 core/turns/derive-turns.ts:405isToolSpan 计入工具 span,与 Codex 推理的既有展示行为不一致。修复方向:在两处 trace-presentation.ts 的 reasoning 分支登记 qwen.thought,或不把 thought part 写成 trace 事件。

[P2] apps/main-1.0/src/core/session-loader.ts:65 QWEN_RUNTIME_DIR/QWEN_HOME 覆盖显式传入的 homeDir
resolveQwenCodeRoot 不接收 options,无条件让环境变量优先,~ 也按 os.homedir() 展开(V2 同 apps/main-2.0/src/core/session-loader.ts:107);同文件的 resolveKimiCodeRoot:79)与 DeepSeek 根解析(:4400)都只在 options.homeDir === undefined 时读取环境变量,把显式 homeDir 当作权威来源。后果:apps/main-2.0/src/core/indexer.test.ts:325 这类传入临时 homeDir 的会话发现用例并未清理这两个变量,在设置了 QWEN_HOME 的机器上会越出临时目录去读真实 ~/.qwen 会话;同时 V1 索引始终传 app.getPath("home")apps/main-1.0/src/main/index.ts:1297),一个与本功能无关的 QWEN_HOME 就会让开关打开后静默索引到 0 个会话且不报错。修复方向:与 kimi/deepseek 对齐,仅在 options.homeDir === undefined 时读取环境变量。

@Dopetaiga
Dopetaiga force-pushed the codex/feat-qwen-code-session-source branch from 63ab7c5 to dc5aa69 Compare August 25, 2026 06:13
@LANSGANBS

Copy link
Copy Markdown
Collaborator

当前模型: anthropic/claude-opus-5-google
审查范围: 2c1b487c...dc5aa69c
覆盖范围: 78/78 diff atoms · 14/14 semantic units

[P2] apps/main-2.0/src/core/session-loaders/alternative-sources.ts:755 Qwen 会话的 projectPath 可能落到 .qwen 存储目录
loadQwenFile 只从 leaf 记录取 cwd(V1 同 apps/main-1.0/src/core/session-loader.ts:2112),而 qwenConversationLeaf 允许 system 记录成为 leaf;当会话末尾是 custom_titlerewind 这类不带 cwd 的 system 记录时(本 PR 自带的 session-loader-qwen.test.ts 首个 fixture 即为该形态),projectPath 会回退成 ~/.qwen/projects/<dir> 存储目录而不是真实工作目录,同一项目的 Qwen 会话因此在项目分组与筛选里被拆到一个伪项目下。修复方向:像同函数里的 gitBranch 一样在 activeRows 中回溯取最后一个非空 cwd,并把兜底值改为 ""(与 loadKimiCodeSessionFile 等既有来源一致)。

[P2] apps/main-1.0/src/main/index.ts:1296 V1 索引移除 homeDir 顺带打开了 Kimi/DeepSeek 的环境变量根解析
runIndexSync 删掉 homeDir: app.getPath("home") 后,生产索引路径上的 options.homeDir 变为 undefined,而 session-loader.ts:79resolveKimiCodeRoot:4401 的 DeepSeek 根解析都把 options.homeDir === undefined 当作“允许读环境变量”的开关;于是在设置了 KIMI_CODE_HOMEDSH_HOME 的机器上,这两个与本 PR 无关的来源会切换到另一个根目录,原先已索引的会话文件不再被扫描而变成陈旧条目。修复方向:保留 homeDir,改为给 Qwen 单独的显式 root 选项或环境变量开关,不要用 homeDir 是否传入来间接控制其他来源的根解析。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sessions): 支持索引 Qwen Code 本地会话

2 participants