Skip to content

fix(core): restore file history snapshots on resume#4253

Open
Alexxigang wants to merge 2 commits into
QwenLM:mainfrom
Alexxigang:fix/resume-file-history-snapshots
Open

fix(core): restore file history snapshots on resume#4253
Alexxigang wants to merge 2 commits into
QwenLM:mainfrom
Alexxigang:fix/resume-file-history-snapshots

Conversation

@Alexxigang

Copy link
Copy Markdown
Contributor

Summary

  • What changed: Persist file-history snapshots into session chat records and restore them when a session is resumed.
  • Why it changed: FileHistoryService already supports snapshot export/import, but the resume path never hydrated those snapshots back into the service, so resumed sessions lost file-history state.
  • Reviewer focus: Please focus on whether restoring snapshots from the reconstructed active branch is the right lifecycle for /resume and whether recording one snapshot per user turn matches the intended persistence model.

Validation

  • Commands run:
    cd packages/core
    npm run typecheck
    npm run build
    npx vitest run src/services/chatRecordingService.test.ts src/services/sessionService.test.ts src/config/config.test.ts src/core/client.test.ts
    npx eslint src/services/chatRecordingService.ts src/services/fileHistoryService.ts src/services/sessionService.ts src/config/config.ts src/core/client.ts src/services/chatRecordingService.test.ts src/services/sessionService.test.ts src/config/config.test.ts
  • Prompts / inputs used: Resumed-session fixtures carrying serialized file_history_snapshot system records; runtime snapshot recording path through GeminiClient user-query handling.
  • Expected result: Resumed sessions recover previously recorded file-history snapshots with Date fields restored, and adjacent core tests keep passing.
  • Observed result: npm run typecheck passed, npm run build passed, 4 targeted test files passed (357 tests), and ESLint passed on all touched files.
  • Quickest reviewer verification path: Run the 4 targeted test files above and inspect SessionService.loadSession() plus Config.getFileHistoryService() restore flow.
  • Evidence (output, logs, screenshots, video, JSON, before/after, etc.): Local verification showed TYPECHECK_EXIT=0, BUILD_EXIT=0, TEST_EXIT=0, LINT_EXIT=0 with 357 passing tests.

Scope / Risk

  • Main risk or tradeoff: Session logs now append one file_history_snapshot system record per user-turn snapshot, which increases session-record size in exchange for correct resume behavior.
  • Not covered / not validated: I did not run a full interactive end-to-end CLI resume flow in this environment.
  • Breaking changes / migration notes: None.

Testing Matrix

macOS Linux Windows
npm run Not tested Not tested Tested
npx Not tested Not tested Tested
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Validation was run locally on Windows in packages/core only.

Linked Issues / Bugs

Comment thread packages/core/src/config/config.test.ts Outdated
expect(snapshots[0]?.timestamp).toBeInstanceOf(Date);
expect(
snapshots[0]?.trackedFileBackups['/tmp/src/app.ts']?.backupTime,
).toBeInstanceOf(Date);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Test failure: backupTime assertion expects a Date instance but receives undefined.

The test "restores persisted file-history snapshots when resuming a session" fails because snapshots[0]?.trackedFileBackups['/tmp/src/app.ts']?.backupTime is undefined instead of a Date. This indicates that the snapshot restoration path through FileHistoryService.restoreFromSnapshots() is not properly hydrating the backupTime field when the input uses a string-typed-but-cast-as-Date value (as happens when snapshots are deserialized from JSONL).

This is the core feature of this PR — if the restoration test itself fails, the feature is not working as intended.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

);
}
if (
!this.fileHistoryRestoredFromSessionData &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] restoreFromSnapshots() called without try/catch — a single malformed snapshot makes file history permanently broken for the session.

In getFileHistoryService(), the restoreFromSnapshots() call is not wrapped in try/catch. If it throws (e.g., corrupted trackedFileBackups from a malformed JSONL record), fileHistoryRestoredFromSessionData stays false while this.fileHistoryService has already been assigned. Every subsequent call to getFileHistoryService() will retry the restore and throw again — an unrecoverable persistent failure. This propagates to TUI render paths (DialogManager.tsx passes config.getFileHistoryService() as a prop) and tool execution, potentially crashing the RewindSelector dialog or blocking file operations.

Suggested change
!this.fileHistoryRestoredFromSessionData &&
if (
!this.fileHistoryRestoredFromSessionData &&
this.sessionData?.fileHistorySnapshots
) {
try {
this.fileHistoryService.restoreFromSnapshots(
this.sessionData.fileHistorySnapshots,
);
} catch (e) {
debugLogger.error(
`FileHistory: Failed to restore snapshots from session data: ${e}`,
);
}
this.fileHistoryRestoredFromSessionData = true;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@@ -315,9 +315,22 @@ export class FileHistoryService {
for (const [p, backup] of Object.entries(snapshot.trackedFileBackups)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] restoreFromSnapshots() lacks structural guards against malformed snapshot data.

The method iterates Object.entries(snapshot.trackedFileBackups) and spreads ...backup without checking whether snapshot, snapshot.trackedFileBackups, or backup are actually the expected types. A corrupted JSONL record (e.g., trackedFileBackups: null or a backup entry that is null instead of an object) would throw TypeErrors with no diagnostic context about which snapshot or file path caused the failure. Combined with the missing try/catch in the caller (config.ts), this becomes a persistent unrecoverable error.

Suggested change
for (const [p, backup] of Object.entries(snapshot.trackedFileBackups)) {
for (const snapshot of snapshots) {
if (!snapshot?.trackedFileBackups || !snapshot?.promptId) continue;
const trackedFileBackups: Record<string, FileHistoryBackup> = {};
for (const [p, backup] of Object.entries(snapshot.trackedFileBackups)) {
if (!backup || typeof backup !== 'object') continue;
const trackingPath = this.maybeShortenFilePath(p);
trackedFiles.add(trackingPath);
trackedFileBackups[trackingPath] = {
...backup,
backupTime:
backup.backupTime instanceof Date
? backup.backupTime
: new Date(backup.backupTime),
};
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@@ -1241,7 +1241,14 @@ export class GeminiClient {

if (messageType === SendMessageType.UserQuery) {
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The catch block's error message is misleading: it always logs "makeSnapshot failed" but the try block now also calls getFileHistoryService() (which can throw from restoreFromSnapshots), getSnapshots(), and recordFileHistorySnapshot(). Any exception from those operations will be misattributed to makeSnapshot, sending anyone debugging the issue on a wild goose chase.

Suggested change
try {
} catch (e) {
debugLogger.error(
`FileHistory: snapshot/record cycle failed for ${prompt_id}: ${e}`,
);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}
}

function extractFileHistorySnapshots(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] extractFileHistorySnapshots() validates payload?.version !== 1 || !snapshot but never checks that snapshot has the required fields (promptId, trackedFileBackups, timestamp) or that trackedFileBackups is an object. A record with { version: 1, snapshot: { promptId: "x" } } (missing trackedFileBackups) would pass through, reach restoreFromSnapshots(), and throw a TypeError on Object.entries(undefined). Validation closer to the ingestion point improves debuggability.

Suggested change
function extractFileHistorySnapshots(
const snapshot = payload?.snapshot;
if (
payload?.version !== 1 ||
!snapshot ||
!snapshot.promptId ||
!snapshot.trackedFileBackups ||
typeof snapshot.trackedFileBackups !== 'object'
) {
continue;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/core/src/core/client.ts Outdated
try {
const fileHistoryService = this.config.getFileHistoryService();
await fileHistoryService.makeSnapshot(prompt_id);
const latestSnapshot = fileHistoryService.getSnapshots().at(-1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new snapshot recording integration (lines 1244-1251) has zero test coverage in client.test.ts. This is the central hook where makeSnapshot output is persisted to the session JSONL log — the success path (promptId matches), the mismatch path (snapshot was a no-op), and the error path are all untested. Consider adding at least a unit test that verifies recordFileHistorySnapshot is called with the correct snapshot after makeSnapshot.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}

const trackingPath = this.maybeShortenFilePath(p);
trackedFiles.add(trackingPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Path traversal risk from session JSONL data. The new data flow lets file paths from session JSONL records enter restoreFromSnapshots()maybeShortenFilePath() and ultimately into trackedFiles set. Paths containing ../ sequences that survive maybeShortenFilePath (which passes non-absolute paths through unchanged) would later be expanded via maybeExpandFilePath (join(this.cwd, filePath)) and used in filesystem ops (stat, readFile, copyFile). While the JSONL files are user-owned, consider adding a resolved.startsWith(this.cwd) check in restoreFromSnapshots as defense-in-depth to prevent restored paths from escaping the project root.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

收回 01:32 的 APPROVED——本地实测后发现 PR 不是看着的 +12631/-12279,而是 ~406 行真实改动 + ~12k 行 EOL 污染。完整对账:

1. 行尾污染(阻塞

4 个核心文件被整文件从 LF 转成 CRLF:

文件 raw diff 真实语义改动 CRLF 占比
client.ts 3415 行 15 行 1860/1860 全部 CRLF
config.ts 6429 行 85 行 大量
sessionService.ts 2384 行 47 行 1356/1356 全部 CRLF
fileHistoryService.ts 1292 行 50 行 760/760 全部 CRLF

核实命令:grep -c $'\r$' packages/core/src/core/client.ts 返回 1860(=该文件总行数)。

后果:

  • git blame 全部断在这条 commit
  • 此后跟 main 上每一条改这些文件的 PR 都冲突(已经实际触发 config.ts/config.test.ts/client.ts/sessionService.test.ts 4 个冲突)
  • 历史导航污染 ~12k 行

看起来是作者本地编辑器在 Windows 上把行尾改了。修法:

git config core.autocrlf input    # 或 false
dos2unix packages/core/src/{core/client.ts,config/config.ts,services/sessionService.ts,services/fileHistoryService.ts}
git add -A && git commit --amend --no-edit
git rebase origin/main

顺便加一个 .gitattributes 杜绝重发:

* text=auto eol=lf
*.{ts,tsx,js,mjs,cjs,json,md,yml,yaml} text eol=lf

2. CI 没跑

HEAD fd5c653afcheck-runs 总数 0,status pending。需要 maintainer trigger 一下 workflow(first-time 贡献者 workflow gate)。

3. 内容本身

本地实测 256/256 vitest passed,tsc clean。restoreFromSnapshots 加的结构守卫(toValidDate + isFileHistoryBackupRecord + 字段校验)正确收掉了 17:24 那条 [Critical]。

但 post-approve 我留的两条 [Suggestion] 都还开着,见下面 inline。


优先级:阻塞项 = EOL 污染;EOL 修完之后 CI 重跑绿 + 两条 [Suggestion] 收掉就可以重新 approve。

Comment thread packages/core/src/core/client.ts Outdated
COMPRESSION_PRESERVE_THRESHOLD,
COMPRESSION_TOKEN_THRESHOLD,
};
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] 整个文件 1860/1860 行都是 CRLF(grep -c $'\\r$' 实测)。和 main 比真实语义改动只有 15 行,但 raw diff 是 3415 行。这条 commit 落地后:

建议在本地 PR 分支上:

dos2unix packages/core/src/core/client.ts
git add packages/core/src/core/client.ts && git commit --amend --no-edit

config.ts / sessionService.ts / fileHistoryService.ts 三个文件做同样的操作。仓库根加 .gitattributes * text=auto eol=lf 防止后续再发生。

Comment thread packages/core/src/core/client.ts Outdated
try {
const fileHistoryService = this.config.getFileHistoryService();
await fileHistoryService.makeSnapshot(prompt_id);
const latestSnapshot = fileHistoryService.getSnapshots().at(-1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion, post-approve carry-over] 新加的 snapshot 录入 hook(L1244-1252)在 client.test.ts 里零覆盖。这是 makeSnapshot → JSONL 持久化的中心 hook,断了的话整个 resume 路径都不能恢复 snapshot 状态。

至少补一个 client.test.ts 用例:

  • messageType = UserQuery,stub fileHistoryService.makeSnapshot 返回一个带 promptId 的 snapshot
  • 断言 chatRecordingService.recordFileHistorySnapshot 被以该 snapshot 调用
  • 再加一个 negative case:makeSnapshot 抛错时不传播到调用方(被 try/catch 兜住),但 debugLogger.error 记录了

EOL 修完之后顺手补,不必单独再发一轮。

}

const trackingPath = this.maybeShortenFilePath(p);
trackedFiles.add(trackingPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion, post-approve carry-over] 路径遍历 defense-in-depth:session JSONL 数据 → restoreFromSnapshots()maybeShortenFilePath()trackedFiles Set → maybeExpandFilePath()join(this.cwd, filePath))→ filesystem ops (stat/readFile/copyFile)。

../ 的相对路径会被 maybeShortenFilePath 原样穿过,后面 join 出来的路径可以逃出 cwd。JSONL 是用户自有,attack surface 窄,但加一行 defense-in-depth 防 future regression 很便宜:

const trackingPath = this.maybeShortenFilePath(p);
const resolved = path.resolve(this.cwd, trackingPath);
if (!resolved.startsWith(this.cwd + path.sep) && resolved !== this.cwd) {
  debugLogger.warn(`Skipping snapshot path outside cwd: ${p}`);
  continue;
}
trackedFiles.add(trackingPath);

EOL 修完之后顺手补即可。

@Alexxigang Alexxigang force-pushed the fix/resume-file-history-snapshots branch from fd5c653 to ef246a2 Compare May 18, 2026 14:33
@Alexxigang

Alexxigang commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this PR onto the current main branch, resolved the merge conflicts, and force-pushed the updated branch. Re-ran the targeted core validation on the rebased branch: src/config/config.test.ts, src/services/chatRecordingService.test.ts, src/services/fileHistoryService.test.ts, src/services/sessionService.test.ts, plus lint, typecheck, and build in packages/core.

import { CronScheduler } from '../services/cronScheduler.js';

// Tools only lightweight imports; tool classes are lazy-loaded via dynamic import
// Tools �?only lightweight imports; tool classes are lazy-loaded via dynamic import

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Systematic UTF-8 encoding corruption across ~40 lines in 3 files (config.ts, fileHistoryService.test.ts, sessionService.test.ts). The third byte of multi-byte UTF-8 sequences is replaced with 0x3F (ASCII ?): em-dash (U+2014, e2 80 94) → e2 80 3f, arrow (U+2192) → e2 86 92 3f, (U+2265) → e2 89 65 3f. Verified at byte level via xxd. This is not a rendering artifact — the files are actually corrupted on disk, likely caused by a tool/editor that processed files through a non-UTF-8-aware pipeline.

Impact: comments become mojibake, CI encoding checks would fail, and readability is permanently degraded.

Fix: revert all encoding-only changes and re-apply the semantic changes on top of unmodified source text. On the PR branch:

git checkout origin/main -- packages/core/src/config/config.ts packages/core/src/services/fileHistoryService.test.ts packages/core/src/services/sessionService.test.ts
# Then re-apply only the semantic changes

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

`FileHistory: Failed to restore snapshots from session data: ${e}`,
);
}
this.fileHistoryRestoredFromSessionData = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] fileHistoryRestoredFromSessionData = true is set unconditionally after the try/catch, so if restoreFromSnapshots throws (transient I/O error, corrupted data), the flag prevents any retry on subsequent getFileHistoryService() calls — permanently disabling file-history restoration for the session with only a debug-level log.

Suggested change
this.fileHistoryRestoredFromSessionData = true;
this.fileHistoryService.restoreFromSnapshots(
this.sessionData.fileHistorySnapshots,
);
this.fileHistoryRestoredFromSessionData = true;
} catch (e) {
this.debugLogger.error(
`FileHistory: Failed to restore snapshots from session data: ${e}`,
);
}

Move the flag assignment inside the try block so it only flips on success, allowing retry on the next access.

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

return Number.isNaN(normalized.getTime()) ? null : normalized;
}

function isFileHistoryBackupRecord(value: unknown): value is FileHistoryBackup {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The type guard isFileHistoryBackupRecord accepts any non-null object (!!value && typeof value === 'object'), including arrays and objects without any FileHistoryBackup fields. While downstream toValidDate(backup.backupTime) catches entries missing backupTime, an object with a valid backupTime but missing backupFileName would pass through and produce undefined in path resolution.

Suggested change
function isFileHistoryBackupRecord(value: unknown): value is FileHistoryBackup {
function isFileHistoryBackupRecord(value: unknown): value is FileHistoryBackup {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
'version' in value &&
typeof (value as FileHistoryBackup).version === 'number'
);
}

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

trackedFileBackups,
});
}
this.state = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] restoreFromSnapshots sets this.state.snapshots = migrated without enforcing the MAX_SNAPSHOTS = 100 cap. Eviction only runs inside makeSnapshot. If the JSONL contains more than 100 snapshot records (long session, data corruption), all are loaded into memory and the orphaned-backup cleanup that eviction triggers is never reached for the extras.

Consider capping before assignment:

if (migrated.length > MAX_SNAPSHOTS) {
  migrated = migrated.slice(migrated.length - MAX_SNAPSHOTS);
}

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

});

it('refreshes the telemetry session context with the new session ID', () => {
it('refreshes the telemetry session context with the new session ID', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nice to have] Indentation regression: this it(...) line was re-indented from 4 spaces to 2 spaces, breaking alignment with sibling test cases in the same describe block. Likely an accidental edit during rebase.

Suggested change
it('refreshes the telemetry session context with the new session ID', () => {
it('refreshes the telemetry session context with the new session ID', () => {

— qwen-latest-series-invite-beta-v28 via Qwen Code /review

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Persist file history snapshots into session chat records so /resume correctly restores file-history state — closes the gap between existing export/import capability and the resume hydration path.

@wenshao

wenshao commented May 28, 2026

Copy link
Copy Markdown
Collaborator

PR #4253 本地验证报告

环境

  • macOS: Darwin 25.4.0 (Apple Silicon)
  • Node.js: v22.17.0
  • 分支: fix/resume-file-history-snapshots @ ef246a2c0

构建

  • npm run build --workspace=packages/core通过
  • 全量 npm run build 因 CLI 包构建失败(与本 PR 无关,PR 基于较早的 main,落后 96 个 commit)

测试结果

测试文件 用例数 结果
src/config/config.test.ts 160 ✅ 通过
src/services/sessionService.test.ts 54 ✅ 通过
src/services/fileHistoryService.test.ts 30 ✅ 通过
src/services/chatRecordingService.test.ts 26 ✅ 通过
合计 270 全部通过

类型检查

  • packages/corenpx tsc --noEmit通过 ✅ (零错误)

CI 状态

检查项 状态
Lint
CodeQL
Test (macOS, Node 22.x)
Test (Ubuntu, Node 22.x)
Test (Windows, Node 22.x)

代码审查

核心变更 (9 个文件, +407/-55)

  1. fileHistoryService.ts — 快照恢复增强 (+46/-2)

    • 新增 toValidDate() 辅助函数:安全解析 Date/string/number → Date,处理 JSON 反序列化后 Date 对象变为字符串的问题
    • 新增 isFileHistoryBackupRecord() 类型守卫
    • restoreFromSnapshots() 增加完善的输入验证:检查 promptIdtrackedFileBackups 存在性,逐条验证 timestampbackupTime 有效性,跳过无效记录而非崩溃
  2. chatRecordingService.ts — 新增快照记录 (+26/-0)

    • 新增 FileHistorySnapshotRecordPayload 接口(version: 1, snapshot)
    • 新增 file_history_snapshot subtype 加入 ChatRecord 联合类型
    • 新增 recordFileHistorySnapshot() 方法,将快照追加到 session 记录
  3. sessionService.ts — 从记录中提取快照 (+45/-0)

    • ResumedSessionData 新增可选 fileHistorySnapshots 字段
    • 新增 extractFileHistorySnapshots() 函数:从 ChatRecord 消息流中筛选 file_history_snapshot 类型记录
    • 新增 isFileHistorySnapshotRecordPayload() 类型守卫,验证 version=1 及 snapshot 结构有效性
    • loadSession() 中在返回前调用提取函数
  4. config.ts — 懒恢复快照 (+50/-33)

    • 新增 fileHistoryRestoredFromSessionData 标志防止重复恢复
    • getFileHistoryService() 中首次调用时从 sessionData.fileHistorySnapshots 恢复快照
    • startNewSession() 中重置标志
  5. client.ts — 录制快照 (+11/-2)

    • UserQuery 处理后,取最新快照并通过 recordFileHistorySnapshot() 持久化到 chat record

测试覆盖 (4 个测试文件, +231/-18)

  • config.test.ts: +41 — 测试 getFileHistoryService() 在有 sessionData 时恢复快照,以及多次调用只恢复一次
  • sessionService.test.ts: +121 — 测试 loadSession() 提取 file_history_snapshot 记录、跳过无效 payload、空 snapshot 时 undefined
  • chatRecordingService.test.ts: +28 — 测试 recordFileHistorySnapshot() 写入正确结构
  • fileHistoryService.test.ts: +39 — 测试 restoreFromSnapshots() 对 ISO 字符串日期恢复、无效数据跳过(缺失 promptId、无效 timestamp、无效 backupTime)

⚠️ 需要关注的问题

config.ts 存在 31 处 Unicode 编码损坏

PR 在 config.ts 中引入了 31 处注释中的字符编码损坏:

  • em-dash (U+2014, e2 80 94) 被替换为无效序列 e2 80 3f
  • 例如: // Tools — only// Tools \xe2\x80\x3f only
  • 全部发生在注释中,不影响功能代码
  • 可能原因:Windows 环境下的编辑器字符编码处理问题

建议:请作者在合并前修复这些编码损坏,或使用以下命令修复:

python3 -c "
data = open('packages/core/src/config/config.ts', 'rb').read()
data = data.replace(b'\xe2\x80\x3f', b'\xe2\x80\x94')
open('packages/core/src/config/config.ts', 'wb').write(data)
"

风险评估

  • 功能正确性: 快照持久化→提取→恢复链路完整,Date 反序列化处理正确
  • 防御性编程: 充分的输入验证和类型守卫,无效数据跳过而非崩溃
  • 向后兼容: 新增的 file_history_snapshot subtype 对旧 session 记录透明
  • 性能影响: 每个 user turn 追加一条快照记录,会增加 session 文件体积,但属于合理权衡
  • encoding 问题: config.ts 中 31 处注释编码损坏需要修复

结论

建议修复后合并 ⚠️ — 功能实现正确,测试覆盖全面(270 用例),CI 全绿。但 config.ts 中 31 处 Unicode 编码损坏(em-dash → 无效字节)需要在合并前修复,以保持代码注释的可读性。修复后即可合并。


验证人: wenshao | 时间: 2026-05-29

@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Jun 1, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR restores file-history snapshots on session resume, closing the gap between FileHistoryService's existing export/import capability and the resume hydration path. The architecture is sound: snapshots are persisted to the session JSONL via recordFileHistorySnapshot, extracted on loadSession via extractFileHistorySnapshots (correctly scoped to the active branch via reconstructHistory), and lazily restored in getFileHistoryService() via restoreFromSnapshots.

Verification of prior review items

Several Critical items from @wenshao's earlier review have been addressed in this revision:

  • restoreFromSnapshots() in getFileHistoryService() is now wrapped in try/catch with error logging.
  • restoreFromSnapshots() now has structural guards: validates promptId, trackedFileBackups type, timestamp via toValidDate(), and per-backup backupTime conversion.
  • The backupTime Date assertion in config.test.ts should now pass since toValidDate() converts ISO strings back to Date instances.
  • isFileHistorySnapshotRecordPayload in sessionService.ts now validates version, promptId, and trackedFileBackups structure.
  • The error message in client.ts was updated to "snapshot/record cycle failed" which is more accurate.

Remaining open items from prior review

The following items from @wenshao remain valid on the current commit:

  1. UTF-8 encoding corruption in comments across config.ts, fileHistoryService.test.ts, and sessionService.test.ts (em-dashes corrupted to ?).
  2. isFileHistoryBackupRecord is still overly permissive (accepts any non-null object including arrays).
  3. No MAX_SNAPSHOTS cap in restoreFromSnapshots -- eviction only runs in makeSnapshot.
  4. fileHistoryRestoredFromSessionData = true set unconditionally after try/catch, preventing retry on transient failures.
  5. Indentation regression in config.test.ts line 418.
  6. Missing test coverage in client.test.ts for the snapshot recording integration.

Minor observation (not inline)

restoreFromSnapshots pushes snapshots into migrated even when all their backup entries were filtered out by validation, resulting in snapshots with empty trackedFileBackups: {}. These occupy slots in the snapshots array (counting toward MAX_SNAPSHOTS) but are functionally inert. Consider pruning them: if (Object.keys(trackedFileBackups).length > 0) { migrated.push(...) }.

Overall assessment

The core logic is correct and well-tested. The remaining items are minor cleanup. Once the UTF-8 corruption and indentation regression are fixed, this is ready to merge.

— qwen-code via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The architecture for restoring file-history snapshots on session resume is sound: snapshots are persisted as versioned system records in the JSONL log, extracted during loadSession, and hydrated lazily in getFileHistoryService(). The validation in restoreFromSnapshots properly guards against malformed data with toValidDate and structural checks.

However, there is a blocking issue that must be fixed before merge:

Blocking: UTF-8 encoding corruption (50 lines across 3 files)

The third byte of multi-byte UTF-8 sequences is replaced with 0x3F (ASCII ?) throughout comments in config.ts (33 lines), fileHistoryService.test.ts (10 lines), and sessionService.test.ts (7 lines). For example, em-dash (U+2014, bytes E2 80 94) becomes E2 80 3F, and arrow (U+2192, bytes E2 86 92) becomes E2 86 3F. These are not cosmetic — the files contain invalid byte sequences that grep treats as binary. This was also flagged by @wenshao (comment ID 3259962409).

Fix: Run dos2unix or equivalent to restore the correct third byte on all affected lines, then verify with grep -Pc '\xe2[\x80-\x8f]\x3f' <file> returning 0 for each.

Other open items (already flagged, not duplicated here)

  • restoreFromSnapshots bypasses the MAX_SNAPSHOTS = 100 cap (comment 3259962442)
  • isFileHistoryBackupRecord type guard accepts arrays (comment 3259962428)
  • fileHistoryRestoredFromSessionData set unconditionally after try/catch prevents retries (comment 3259962417)
  • Indentation regression in config.test.ts:418 (comment 3259962451)

— qwen3-coder via Qwen Code /review

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

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants