@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.
- package/LICENSE +21 -21
- package/README.md +236 -236
- package/bin/cli.mjs +52 -52
- package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/index.html +26 -26
- package/package.json +85 -83
- package/server/index.ts +130 -130
- package/server/lib/active-sessions.test.ts +119 -119
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -182
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -126
- package/server/lib/claude-paths.ts +43 -43
- package/server/lib/cleanup-suggestions.ts +131 -131
- package/server/lib/constants.ts +8 -8
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -244
- package/server/lib/delete.ts +192 -192
- package/server/lib/disk-usage.ts +81 -81
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -337
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -280
- package/server/lib/modified-files.ts +228 -228
- package/server/lib/open-folder.ts +47 -47
- package/server/lib/parse-jsonl.ts +160 -139
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -41
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -73
- package/server/lib/safe-remove.ts +25 -25
- package/server/lib/scan.ts +289 -286
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -67
- package/server/lib/version.test.ts +39 -39
- package/server/lib/version.ts +117 -117
- package/server/routes/disk-cleanup.ts +54 -54
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -130
- package/server/routes/version.ts +34 -34
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -7
- package/shared/types.ts +513 -511
- package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
- package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
- package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
- package/dist/assets/index-7aMrnHJG.js +0 -7
- package/dist/assets/index-7aMrnHJG.js.map +0 -1
- 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
|
+
});
|