@zzusp/ccsm 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -236
  3. package/bin/cli.mjs +52 -52
  4. package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
  5. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  6. package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
  7. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  9. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  10. package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
  11. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  12. package/dist/assets/index-CrWxV6sb.css +1 -0
  13. package/dist/assets/index-DTbWl1jb.js +11 -0
  14. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  15. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  16. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  17. package/dist/index.html +26 -26
  18. package/package.json +85 -83
  19. package/server/index.ts +130 -130
  20. package/server/lib/active-sessions.test.ts +119 -119
  21. package/server/lib/active-sessions.ts +95 -95
  22. package/server/lib/bundle.test.ts +182 -182
  23. package/server/lib/bundle.ts +86 -86
  24. package/server/lib/claude-paths.test.ts +126 -126
  25. package/server/lib/claude-paths.ts +43 -43
  26. package/server/lib/cleanup-suggestions.ts +131 -131
  27. package/server/lib/constants.ts +8 -8
  28. package/server/lib/delete-project.ts +100 -100
  29. package/server/lib/delete.test.ts +244 -244
  30. package/server/lib/delete.ts +192 -192
  31. package/server/lib/disk-usage.ts +81 -81
  32. package/server/lib/encode-cwd.ts +24 -24
  33. package/server/lib/export-bundle.ts +236 -236
  34. package/server/lib/export-import-bundle.test.ts +337 -337
  35. package/server/lib/fs-size.ts +38 -38
  36. package/server/lib/import-bundle.ts +488 -488
  37. package/server/lib/load-memory.ts +120 -120
  38. package/server/lib/load-session.ts +209 -209
  39. package/server/lib/modified-files.test.ts +280 -280
  40. package/server/lib/modified-files.ts +228 -228
  41. package/server/lib/open-folder.ts +47 -47
  42. package/server/lib/parse-jsonl.ts +160 -139
  43. package/server/lib/port.ts +23 -23
  44. package/server/lib/safe-id.test.ts +41 -41
  45. package/server/lib/safe-id.ts +6 -6
  46. package/server/lib/safe-remove.test.ts +73 -73
  47. package/server/lib/safe-remove.ts +25 -25
  48. package/server/lib/scan.ts +289 -286
  49. package/server/lib/search-all.ts +130 -130
  50. package/server/lib/search-session.ts +203 -203
  51. package/server/lib/system-tags.ts +20 -20
  52. package/server/lib/update.ts +67 -67
  53. package/server/lib/version.test.ts +39 -39
  54. package/server/lib/version.ts +117 -117
  55. package/server/routes/disk-cleanup.ts +54 -54
  56. package/server/routes/disk.ts +9 -9
  57. package/server/routes/import.ts +87 -87
  58. package/server/routes/projects.ts +104 -104
  59. package/server/routes/search.ts +79 -79
  60. package/server/routes/sessions.ts +130 -130
  61. package/server/routes/version.ts +34 -34
  62. package/server/types.ts +1 -1
  63. package/shared/constants.ts +7 -7
  64. package/shared/types.ts +513 -511
  65. package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
  66. package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
  67. package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
  68. package/dist/assets/index-7aMrnHJG.js +0 -7
  69. package/dist/assets/index-7aMrnHJG.js.map +0 -1
  70. package/dist/assets/index-BOeI_J4B.css +0 -1
@@ -1,280 +1,280 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
-
6
- // 用 CCSM_TEST_ROOT 跨进程桥接的写法跟 delete.test.ts 保持一致——vi.mock 的工厂
7
- // 必须 hoist 安全,闭包里不能引用模块作用域变量。
8
- vi.mock('./claude-paths.ts', () => {
9
- return {
10
- get PATHS() {
11
- const root = process.env.CCSM_TEST_ROOT!;
12
- return {
13
- root,
14
- projects: path.join(root, 'projects'),
15
- fileHistory: path.join(root, 'file-history'),
16
- sessionEnv: path.join(root, 'session-env'),
17
- sessions: path.join(root, 'sessions'),
18
- history: path.join(root, 'history.jsonl'),
19
- };
20
- },
21
- isUnderClaudeRoot(target: string): boolean {
22
- const root = process.env.CCSM_TEST_ROOT!;
23
- const resolved = path.resolve(target);
24
- return resolved === root || resolved.startsWith(root + path.sep);
25
- },
26
- getCacheDir(): string {
27
- return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
28
- },
29
- };
30
- });
31
-
32
- import { loadModifiedFiles } from './modified-files.ts';
33
-
34
- let fakeRoot: string;
35
-
36
- beforeEach(() => {
37
- fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-modfiles-test-'));
38
- process.env.CCSM_TEST_ROOT = fakeRoot;
39
- fs.mkdirSync(path.join(fakeRoot, 'projects'), { recursive: true });
40
- });
41
-
42
- afterEach(() => {
43
- fs.rmSync(fakeRoot, { recursive: true, force: true });
44
- delete process.env.CCSM_TEST_ROOT;
45
- });
46
-
47
- function writeJsonl(projectId: string, sessionId: string, lines: object[]): string {
48
- const dir = path.join(fakeRoot, 'projects', projectId);
49
- fs.mkdirSync(dir, { recursive: true });
50
- const file = path.join(dir, `${sessionId}.jsonl`);
51
- fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
52
- return file;
53
- }
54
-
55
- function userMsg(uuid: string, ts: string, content: unknown): object {
56
- return { type: 'user', uuid, timestamp: ts, message: { content } };
57
- }
58
-
59
- function assistantMsg(uuid: string, ts: string, content: unknown): object {
60
- return { type: 'assistant', uuid, timestamp: ts, message: { content } };
61
- }
62
-
63
- function toolUse(id: string, name: string, input: unknown): object {
64
- return { type: 'tool_use', id, name, input };
65
- }
66
-
67
- function toolResult(toolUseId: string, isError = false): object {
68
- return { type: 'tool_result', tool_use_id: toolUseId, content: '', is_error: isError };
69
- }
70
-
71
- describe('loadModifiedFiles', () => {
72
- it('returns null when session jsonl missing', async () => {
73
- const out = await loadModifiedFiles('proj', 'no-such-sid');
74
- expect(out).toBeNull();
75
- });
76
-
77
- it('aggregates Edit/Write/MultiEdit/NotebookEdit by filePath and counts per-tool', async () => {
78
- writeJsonl('proj-1', 'sid-1', [
79
- { type: 'summary', cwd: '/Users/me/repo' },
80
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
81
- toolUse('t1', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'x', new_string: 'y' }),
82
- ]),
83
- userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1', false)]),
84
- assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
85
- toolUse('t2', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'a', new_string: 'b' }),
86
- ]),
87
- userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t2', false)]),
88
- assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
89
- toolUse('t3', 'Write', { file_path: '/Users/me/repo/src/b.ts', content: 'new file' }),
90
- ]),
91
- userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
92
- assistantMsg('a4', '2026-06-01T10:03:00.000Z', [
93
- toolUse('t4', 'MultiEdit', {
94
- file_path: '/Users/me/repo/src/b.ts',
95
- edits: [{ old_string: 'a', new_string: 'b' }],
96
- }),
97
- ]),
98
- userMsg('u4', '2026-06-01T10:03:01.000Z', [toolResult('t4', false)]),
99
- assistantMsg('a5', '2026-06-01T10:04:00.000Z', [
100
- toolUse('t5', 'NotebookEdit', {
101
- notebook_path: '/Users/me/repo/nb.ipynb',
102
- cell_id: 'c1',
103
- new_source: 'print(1)',
104
- }),
105
- ]),
106
- userMsg('u5', '2026-06-01T10:04:01.000Z', [toolResult('t5', false)]),
107
- ]);
108
-
109
- const out = await loadModifiedFiles('proj-1', 'sid-1');
110
- expect(out).not.toBeNull();
111
- expect(out!.cwd).toBe('/Users/me/repo');
112
- expect(out!.files).toHaveLength(3);
113
-
114
- // 排序 by lastAt desc: nb.ipynb (10:04) → b.ts (10:03) → a.ts (10:01)
115
- const nb = out!.files[0]!;
116
- const b = out!.files[1]!;
117
- const a = out!.files[2]!;
118
- expect(nb.filePath).toBe('/Users/me/repo/nb.ipynb');
119
- expect(nb.notebookEditCount).toBe(1);
120
- expect(nb.relativePath).toBe('nb.ipynb');
121
-
122
- expect(b.filePath).toBe('/Users/me/repo/src/b.ts');
123
- expect(b.relativePath).toBe('src/b.ts');
124
- expect(b.writeCount).toBe(1);
125
- expect(b.multiEditCount).toBe(1);
126
- expect(b.totalCount).toBe(2);
127
-
128
- expect(a.filePath).toBe('/Users/me/repo/src/a.ts');
129
- expect(a.relativePath).toBe('src/a.ts');
130
- expect(a.editCount).toBe(2);
131
- expect(a.totalCount).toBe(2);
132
- expect(a.errorCount).toBe(0);
133
- expect(a.firstAt).toBe('2026-06-01T10:00:00.000Z');
134
- expect(a.lastAt).toBe('2026-06-01T10:01:00.000Z');
135
- expect(a.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
136
- expect(a.operations.every((o) => !o.pending && !o.errored)).toBe(true);
137
- });
138
-
139
- it('marks errored when tool_result.is_error=true and pending when no result line found', async () => {
140
- writeJsonl('proj', 'sid', [
141
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
142
- toolUse('t-err', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
143
- ]),
144
- userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t-err', true)]),
145
- assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
146
- toolUse('t-pend', 'Edit', { file_path: '/repo/x.ts', old_string: 'b', new_string: 'c' }),
147
- ]),
148
- // 没有 t-pend 的 tool_result
149
- ]);
150
-
151
- const out = await loadModifiedFiles('proj', 'sid');
152
- const x = out!.files[0]!;
153
- expect(x.totalCount).toBe(2);
154
- expect(x.errorCount).toBe(1);
155
- const err = x.operations.find((o) => o.toolUseId === 't-err')!;
156
- expect(err.errored).toBe(true);
157
- expect(err.pending).toBe(false);
158
- const pend = x.operations.find((o) => o.toolUseId === 't-pend')!;
159
- expect(pend.errored).toBe(false);
160
- expect(pend.pending).toBe(true);
161
- });
162
-
163
- it('ignores non-file-mutating tools (Bash/Read/Grep/Task)', async () => {
164
- writeJsonl('proj', 'sid', [
165
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
166
- toolUse('t1', 'Bash', { command: 'ls' }),
167
- toolUse('t2', 'Read', { file_path: '/repo/x.ts' }),
168
- toolUse('t3', 'Grep', { pattern: 'foo' }),
169
- ]),
170
- userMsg('u1', '2026-06-01T10:00:01.000Z', [
171
- toolResult('t1', false),
172
- toolResult('t2', false),
173
- toolResult('t3', false),
174
- ]),
175
- ]);
176
- const out = await loadModifiedFiles('proj', 'sid');
177
- expect(out!.files).toEqual([]);
178
- });
179
-
180
- it('relativePath is null when filePath sits outside cwd', async () => {
181
- writeJsonl('proj', 'sid', [
182
- { type: 'summary', cwd: '/Users/me/repo' },
183
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
184
- toolUse('t1', 'Write', { file_path: '/tmp/elsewhere.txt', content: 'hi' }),
185
- ]),
186
- userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1')]),
187
- ]);
188
- const out = await loadModifiedFiles('proj', 'sid');
189
- expect(out!.files[0]!.relativePath).toBeNull();
190
- });
191
-
192
- it('operations within a file are ordered by ts asc', async () => {
193
- writeJsonl('proj', 'sid', [
194
- assistantMsg('a1', '2026-06-01T10:02:00.000Z', [
195
- toolUse('t2', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
196
- ]),
197
- userMsg('u1', '2026-06-01T10:02:01.000Z', [toolResult('t2')]),
198
- assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
199
- toolUse('t1', 'Edit', { file_path: '/repo/x.ts', old_string: 'c', new_string: 'd' }),
200
- ]),
201
- userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t1')]),
202
- ]);
203
- const out = await loadModifiedFiles('proj', 'sid');
204
- expect(out!.files[0]!.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
205
- expect(out!.files[0]!.firstAt).toBe('2026-06-01T10:01:00.000Z');
206
- expect(out!.files[0]!.lastAt).toBe('2026-06-01T10:02:00.000Z');
207
- });
208
-
209
- it('skips malformed jsonl lines and tool_use entries without id/file_path', async () => {
210
- const file = path.join(fakeRoot, 'projects', 'proj', 'sid.jsonl');
211
- fs.mkdirSync(path.dirname(file), { recursive: true });
212
- fs.writeFileSync(
213
- file,
214
- [
215
- '{not json',
216
- JSON.stringify(
217
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
218
- toolUse('', 'Edit', { file_path: '/x.ts', old_string: 'a', new_string: 'b' }), // empty id → skip
219
- toolUse('t1', 'Edit', { /* no file_path */ old_string: 'a', new_string: 'b' }), // no path → skip
220
- toolUse('t2', 'Write', { file_path: '/ok.ts', content: 'hi' }),
221
- ]),
222
- ),
223
- JSON.stringify(userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t2')])),
224
- '',
225
- ].join('\n'),
226
- );
227
- const out = await loadModifiedFiles('proj', 'sid');
228
- expect(out!.files).toHaveLength(1);
229
- expect(out!.files[0]!.filePath).toBe('/ok.ts');
230
- });
231
-
232
- it('attaches structuredPatch from toolUseResult; [] for create, null when absent', async () => {
233
- writeJsonl('proj-sp', 'sid-sp', [
234
- { type: 'summary', cwd: '/repo' },
235
- // Edit whose result line carries a real structuredPatch (accurate file line numbers).
236
- assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
237
- toolUse('t1', 'Edit', { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }),
238
- ]),
239
- {
240
- type: 'user',
241
- uuid: 'u1',
242
- timestamp: '2026-06-01T10:00:01.000Z',
243
- message: { content: [toolResult('t1', false)] },
244
- toolUseResult: {
245
- type: 'update',
246
- filePath: '/repo/a.ts',
247
- structuredPatch: [
248
- { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
249
- ],
250
- },
251
- },
252
- // Write create → structuredPatch is an empty array (render input as all-added).
253
- assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
254
- toolUse('t2', 'Write', { file_path: '/repo/b.ts', content: 'new' }),
255
- ]),
256
- {
257
- type: 'user',
258
- uuid: 'u2',
259
- timestamp: '2026-06-01T10:01:01.000Z',
260
- message: { content: [toolResult('t2', false)] },
261
- toolUseResult: { type: 'create', filePath: '/repo/b.ts', structuredPatch: [] },
262
- },
263
- // Edit whose result has no toolUseResult sentinel → null (fall back to input diff).
264
- assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
265
- toolUse('t3', 'Edit', { file_path: '/repo/c.ts', old_string: 'm', new_string: 'n' }),
266
- ]),
267
- userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
268
- ]);
269
-
270
- const out = await loadModifiedFiles('proj-sp', 'sid-sp');
271
- expect(out).not.toBeNull();
272
- const byPath = new Map(out!.files.map((f) => [f.filePath, f]));
273
-
274
- expect(byPath.get('/repo/a.ts')!.operations[0]!.structuredPatch).toEqual([
275
- { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
276
- ]);
277
- expect(byPath.get('/repo/b.ts')!.operations[0]!.structuredPatch).toEqual([]);
278
- expect(byPath.get('/repo/c.ts')!.operations[0]!.structuredPatch).toBeNull();
279
- });
280
- });
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ // 用 CCSM_TEST_ROOT 跨进程桥接的写法跟 delete.test.ts 保持一致——vi.mock 的工厂
7
+ // 必须 hoist 安全,闭包里不能引用模块作用域变量。
8
+ vi.mock('./claude-paths.ts', () => {
9
+ return {
10
+ get PATHS() {
11
+ const root = process.env.CCSM_TEST_ROOT!;
12
+ return {
13
+ root,
14
+ projects: path.join(root, 'projects'),
15
+ fileHistory: path.join(root, 'file-history'),
16
+ sessionEnv: path.join(root, 'session-env'),
17
+ sessions: path.join(root, 'sessions'),
18
+ history: path.join(root, 'history.jsonl'),
19
+ };
20
+ },
21
+ isUnderClaudeRoot(target: string): boolean {
22
+ const root = process.env.CCSM_TEST_ROOT!;
23
+ const resolved = path.resolve(target);
24
+ return resolved === root || resolved.startsWith(root + path.sep);
25
+ },
26
+ getCacheDir(): string {
27
+ return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
28
+ },
29
+ };
30
+ });
31
+
32
+ import { loadModifiedFiles } from './modified-files.ts';
33
+
34
+ let fakeRoot: string;
35
+
36
+ beforeEach(() => {
37
+ fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-modfiles-test-'));
38
+ process.env.CCSM_TEST_ROOT = fakeRoot;
39
+ fs.mkdirSync(path.join(fakeRoot, 'projects'), { recursive: true });
40
+ });
41
+
42
+ afterEach(() => {
43
+ fs.rmSync(fakeRoot, { recursive: true, force: true });
44
+ delete process.env.CCSM_TEST_ROOT;
45
+ });
46
+
47
+ function writeJsonl(projectId: string, sessionId: string, lines: object[]): string {
48
+ const dir = path.join(fakeRoot, 'projects', projectId);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ const file = path.join(dir, `${sessionId}.jsonl`);
51
+ fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
52
+ return file;
53
+ }
54
+
55
+ function userMsg(uuid: string, ts: string, content: unknown): object {
56
+ return { type: 'user', uuid, timestamp: ts, message: { content } };
57
+ }
58
+
59
+ function assistantMsg(uuid: string, ts: string, content: unknown): object {
60
+ return { type: 'assistant', uuid, timestamp: ts, message: { content } };
61
+ }
62
+
63
+ function toolUse(id: string, name: string, input: unknown): object {
64
+ return { type: 'tool_use', id, name, input };
65
+ }
66
+
67
+ function toolResult(toolUseId: string, isError = false): object {
68
+ return { type: 'tool_result', tool_use_id: toolUseId, content: '', is_error: isError };
69
+ }
70
+
71
+ describe('loadModifiedFiles', () => {
72
+ it('returns null when session jsonl missing', async () => {
73
+ const out = await loadModifiedFiles('proj', 'no-such-sid');
74
+ expect(out).toBeNull();
75
+ });
76
+
77
+ it('aggregates Edit/Write/MultiEdit/NotebookEdit by filePath and counts per-tool', async () => {
78
+ writeJsonl('proj-1', 'sid-1', [
79
+ { type: 'summary', cwd: '/Users/me/repo' },
80
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
81
+ toolUse('t1', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'x', new_string: 'y' }),
82
+ ]),
83
+ userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1', false)]),
84
+ assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
85
+ toolUse('t2', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'a', new_string: 'b' }),
86
+ ]),
87
+ userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t2', false)]),
88
+ assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
89
+ toolUse('t3', 'Write', { file_path: '/Users/me/repo/src/b.ts', content: 'new file' }),
90
+ ]),
91
+ userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
92
+ assistantMsg('a4', '2026-06-01T10:03:00.000Z', [
93
+ toolUse('t4', 'MultiEdit', {
94
+ file_path: '/Users/me/repo/src/b.ts',
95
+ edits: [{ old_string: 'a', new_string: 'b' }],
96
+ }),
97
+ ]),
98
+ userMsg('u4', '2026-06-01T10:03:01.000Z', [toolResult('t4', false)]),
99
+ assistantMsg('a5', '2026-06-01T10:04:00.000Z', [
100
+ toolUse('t5', 'NotebookEdit', {
101
+ notebook_path: '/Users/me/repo/nb.ipynb',
102
+ cell_id: 'c1',
103
+ new_source: 'print(1)',
104
+ }),
105
+ ]),
106
+ userMsg('u5', '2026-06-01T10:04:01.000Z', [toolResult('t5', false)]),
107
+ ]);
108
+
109
+ const out = await loadModifiedFiles('proj-1', 'sid-1');
110
+ expect(out).not.toBeNull();
111
+ expect(out!.cwd).toBe('/Users/me/repo');
112
+ expect(out!.files).toHaveLength(3);
113
+
114
+ // 排序 by lastAt desc: nb.ipynb (10:04) → b.ts (10:03) → a.ts (10:01)
115
+ const nb = out!.files[0]!;
116
+ const b = out!.files[1]!;
117
+ const a = out!.files[2]!;
118
+ expect(nb.filePath).toBe('/Users/me/repo/nb.ipynb');
119
+ expect(nb.notebookEditCount).toBe(1);
120
+ expect(nb.relativePath).toBe('nb.ipynb');
121
+
122
+ expect(b.filePath).toBe('/Users/me/repo/src/b.ts');
123
+ expect(b.relativePath).toBe('src/b.ts');
124
+ expect(b.writeCount).toBe(1);
125
+ expect(b.multiEditCount).toBe(1);
126
+ expect(b.totalCount).toBe(2);
127
+
128
+ expect(a.filePath).toBe('/Users/me/repo/src/a.ts');
129
+ expect(a.relativePath).toBe('src/a.ts');
130
+ expect(a.editCount).toBe(2);
131
+ expect(a.totalCount).toBe(2);
132
+ expect(a.errorCount).toBe(0);
133
+ expect(a.firstAt).toBe('2026-06-01T10:00:00.000Z');
134
+ expect(a.lastAt).toBe('2026-06-01T10:01:00.000Z');
135
+ expect(a.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
136
+ expect(a.operations.every((o) => !o.pending && !o.errored)).toBe(true);
137
+ });
138
+
139
+ it('marks errored when tool_result.is_error=true and pending when no result line found', async () => {
140
+ writeJsonl('proj', 'sid', [
141
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
142
+ toolUse('t-err', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
143
+ ]),
144
+ userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t-err', true)]),
145
+ assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
146
+ toolUse('t-pend', 'Edit', { file_path: '/repo/x.ts', old_string: 'b', new_string: 'c' }),
147
+ ]),
148
+ // 没有 t-pend 的 tool_result
149
+ ]);
150
+
151
+ const out = await loadModifiedFiles('proj', 'sid');
152
+ const x = out!.files[0]!;
153
+ expect(x.totalCount).toBe(2);
154
+ expect(x.errorCount).toBe(1);
155
+ const err = x.operations.find((o) => o.toolUseId === 't-err')!;
156
+ expect(err.errored).toBe(true);
157
+ expect(err.pending).toBe(false);
158
+ const pend = x.operations.find((o) => o.toolUseId === 't-pend')!;
159
+ expect(pend.errored).toBe(false);
160
+ expect(pend.pending).toBe(true);
161
+ });
162
+
163
+ it('ignores non-file-mutating tools (Bash/Read/Grep/Task)', async () => {
164
+ writeJsonl('proj', 'sid', [
165
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
166
+ toolUse('t1', 'Bash', { command: 'ls' }),
167
+ toolUse('t2', 'Read', { file_path: '/repo/x.ts' }),
168
+ toolUse('t3', 'Grep', { pattern: 'foo' }),
169
+ ]),
170
+ userMsg('u1', '2026-06-01T10:00:01.000Z', [
171
+ toolResult('t1', false),
172
+ toolResult('t2', false),
173
+ toolResult('t3', false),
174
+ ]),
175
+ ]);
176
+ const out = await loadModifiedFiles('proj', 'sid');
177
+ expect(out!.files).toEqual([]);
178
+ });
179
+
180
+ it('relativePath is null when filePath sits outside cwd', async () => {
181
+ writeJsonl('proj', 'sid', [
182
+ { type: 'summary', cwd: '/Users/me/repo' },
183
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
184
+ toolUse('t1', 'Write', { file_path: '/tmp/elsewhere.txt', content: 'hi' }),
185
+ ]),
186
+ userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1')]),
187
+ ]);
188
+ const out = await loadModifiedFiles('proj', 'sid');
189
+ expect(out!.files[0]!.relativePath).toBeNull();
190
+ });
191
+
192
+ it('operations within a file are ordered by ts asc', async () => {
193
+ writeJsonl('proj', 'sid', [
194
+ assistantMsg('a1', '2026-06-01T10:02:00.000Z', [
195
+ toolUse('t2', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
196
+ ]),
197
+ userMsg('u1', '2026-06-01T10:02:01.000Z', [toolResult('t2')]),
198
+ assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
199
+ toolUse('t1', 'Edit', { file_path: '/repo/x.ts', old_string: 'c', new_string: 'd' }),
200
+ ]),
201
+ userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t1')]),
202
+ ]);
203
+ const out = await loadModifiedFiles('proj', 'sid');
204
+ expect(out!.files[0]!.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
205
+ expect(out!.files[0]!.firstAt).toBe('2026-06-01T10:01:00.000Z');
206
+ expect(out!.files[0]!.lastAt).toBe('2026-06-01T10:02:00.000Z');
207
+ });
208
+
209
+ it('skips malformed jsonl lines and tool_use entries without id/file_path', async () => {
210
+ const file = path.join(fakeRoot, 'projects', 'proj', 'sid.jsonl');
211
+ fs.mkdirSync(path.dirname(file), { recursive: true });
212
+ fs.writeFileSync(
213
+ file,
214
+ [
215
+ '{not json',
216
+ JSON.stringify(
217
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
218
+ toolUse('', 'Edit', { file_path: '/x.ts', old_string: 'a', new_string: 'b' }), // empty id → skip
219
+ toolUse('t1', 'Edit', { /* no file_path */ old_string: 'a', new_string: 'b' }), // no path → skip
220
+ toolUse('t2', 'Write', { file_path: '/ok.ts', content: 'hi' }),
221
+ ]),
222
+ ),
223
+ JSON.stringify(userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t2')])),
224
+ '',
225
+ ].join('\n'),
226
+ );
227
+ const out = await loadModifiedFiles('proj', 'sid');
228
+ expect(out!.files).toHaveLength(1);
229
+ expect(out!.files[0]!.filePath).toBe('/ok.ts');
230
+ });
231
+
232
+ it('attaches structuredPatch from toolUseResult; [] for create, null when absent', async () => {
233
+ writeJsonl('proj-sp', 'sid-sp', [
234
+ { type: 'summary', cwd: '/repo' },
235
+ // Edit whose result line carries a real structuredPatch (accurate file line numbers).
236
+ assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
237
+ toolUse('t1', 'Edit', { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }),
238
+ ]),
239
+ {
240
+ type: 'user',
241
+ uuid: 'u1',
242
+ timestamp: '2026-06-01T10:00:01.000Z',
243
+ message: { content: [toolResult('t1', false)] },
244
+ toolUseResult: {
245
+ type: 'update',
246
+ filePath: '/repo/a.ts',
247
+ structuredPatch: [
248
+ { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
249
+ ],
250
+ },
251
+ },
252
+ // Write create → structuredPatch is an empty array (render input as all-added).
253
+ assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
254
+ toolUse('t2', 'Write', { file_path: '/repo/b.ts', content: 'new' }),
255
+ ]),
256
+ {
257
+ type: 'user',
258
+ uuid: 'u2',
259
+ timestamp: '2026-06-01T10:01:01.000Z',
260
+ message: { content: [toolResult('t2', false)] },
261
+ toolUseResult: { type: 'create', filePath: '/repo/b.ts', structuredPatch: [] },
262
+ },
263
+ // Edit whose result has no toolUseResult sentinel → null (fall back to input diff).
264
+ assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
265
+ toolUse('t3', 'Edit', { file_path: '/repo/c.ts', old_string: 'm', new_string: 'n' }),
266
+ ]),
267
+ userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
268
+ ]);
269
+
270
+ const out = await loadModifiedFiles('proj-sp', 'sid-sp');
271
+ expect(out).not.toBeNull();
272
+ const byPath = new Map(out!.files.map((f) => [f.filePath, f]));
273
+
274
+ expect(byPath.get('/repo/a.ts')!.operations[0]!.structuredPatch).toEqual([
275
+ { oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
276
+ ]);
277
+ expect(byPath.get('/repo/b.ts')!.operations[0]!.structuredPatch).toEqual([]);
278
+ expect(byPath.get('/repo/c.ts')!.operations[0]!.structuredPatch).toBeNull();
279
+ });
280
+ });