clawt 2.16.4 → 2.16.5
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/.claude/agent-memory/docs-sync-updater/MEMORY.md +13 -1
- package/dist/index.js +281 -57
- package/dist/postinstall.js +2 -2
- package/docs/spec.md +49 -4
- package/package.json +2 -1
- package/src/constants/index.ts +12 -2
- package/src/constants/messages/run.ts +4 -4
- package/src/constants/progress.ts +36 -6
- package/src/utils/index.ts +2 -0
- package/src/utils/progress-render.ts +77 -13
- package/src/utils/progress.ts +110 -24
- package/src/utils/stream-parser.ts +251 -0
- package/src/utils/task-executor.ts +61 -27
- package/tests/unit/utils/progress-render.test.ts +96 -0
- package/tests/unit/utils/progress.test.ts +391 -10
- package/tests/unit/utils/stream-parser.test.ts +375 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createLineBuffer,
|
|
4
|
+
parseStreamLine,
|
|
5
|
+
parseStreamEvent,
|
|
6
|
+
formatActivityText,
|
|
7
|
+
truncateText,
|
|
8
|
+
} from '../../../src/utils/stream-parser.js';
|
|
9
|
+
import type { StreamEvent } from '../../../src/utils/stream-parser.js';
|
|
10
|
+
|
|
11
|
+
describe('stream-parser', () => {
|
|
12
|
+
// ============================================================
|
|
13
|
+
// createLineBuffer
|
|
14
|
+
// ============================================================
|
|
15
|
+
describe('createLineBuffer', () => {
|
|
16
|
+
it('单行完整数据正常返回', () => {
|
|
17
|
+
const buf = createLineBuffer();
|
|
18
|
+
const lines = buf.push('hello\n');
|
|
19
|
+
expect(lines).toEqual(['hello']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('多行数据一次返回多行', () => {
|
|
23
|
+
const buf = createLineBuffer();
|
|
24
|
+
const lines = buf.push('line1\nline2\nline3\n');
|
|
25
|
+
expect(lines).toEqual(['line1', 'line2', 'line3']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('跨越多个 chunk 的行正确拼接', () => {
|
|
29
|
+
const buf = createLineBuffer();
|
|
30
|
+
const lines1 = buf.push('hel');
|
|
31
|
+
expect(lines1).toEqual([]);
|
|
32
|
+
|
|
33
|
+
const lines2 = buf.push('lo\n');
|
|
34
|
+
expect(lines2).toEqual(['hello']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('flush 返回未完成的行', () => {
|
|
38
|
+
const buf = createLineBuffer();
|
|
39
|
+
buf.push('incomplete');
|
|
40
|
+
const remaining = buf.flush();
|
|
41
|
+
expect(remaining).toBe('incomplete');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('flush 在缓冲区为空时返回 null', () => {
|
|
45
|
+
const buf = createLineBuffer();
|
|
46
|
+
buf.push('complete\n');
|
|
47
|
+
const remaining = buf.flush();
|
|
48
|
+
expect(remaining).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('连续调用 push 不丢数据', () => {
|
|
52
|
+
const buf = createLineBuffer();
|
|
53
|
+
buf.push('aa');
|
|
54
|
+
buf.push('bb');
|
|
55
|
+
buf.push('cc\ndd');
|
|
56
|
+
const lines = buf.push('ee\n');
|
|
57
|
+
expect(lines).toEqual(['ddee']);
|
|
58
|
+
|
|
59
|
+
// flush 后缓冲区应为空
|
|
60
|
+
expect(buf.flush()).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ============================================================
|
|
65
|
+
// parseStreamLine
|
|
66
|
+
// ============================================================
|
|
67
|
+
describe('parseStreamLine', () => {
|
|
68
|
+
it('空行返回 null', () => {
|
|
69
|
+
expect(parseStreamLine('')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('空白行(只有空格/制表符)返回 null', () => {
|
|
73
|
+
expect(parseStreamLine(' \t ')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('合法 JSON 行返回解析后的对象', () => {
|
|
77
|
+
const result = parseStreamLine('{"type":"result","is_error":false}');
|
|
78
|
+
expect(result).toEqual({ type: 'result', is_error: false });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('非 JSON 文本返回 null', () => {
|
|
82
|
+
expect(parseStreamLine('this is not json')).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('含有首尾空白的 JSON 行正确解析', () => {
|
|
86
|
+
const result = parseStreamLine(' {"type":"system"} ');
|
|
87
|
+
expect(result).toEqual({ type: 'system' });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// truncateText
|
|
93
|
+
// ============================================================
|
|
94
|
+
describe('truncateText', () => {
|
|
95
|
+
it('短文本不截断', () => {
|
|
96
|
+
expect(truncateText('hello', 10)).toBe('hello');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('恰好等于长度的文本不截断', () => {
|
|
100
|
+
expect(truncateText('12345', 5)).toBe('12345');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('长文本截断到指定长度并追加省略号', () => {
|
|
104
|
+
const result = truncateText('这是一段很长的文本需要被截断', 10);
|
|
105
|
+
expect(result).toHaveLength(10);
|
|
106
|
+
expect(result.endsWith('…')).toBe(true);
|
|
107
|
+
// maxLength=10 时截取前 9 个字符 + 省略号
|
|
108
|
+
expect(result).toBe('这是一段很长的文本…');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ============================================================
|
|
113
|
+
// formatActivityText
|
|
114
|
+
// ============================================================
|
|
115
|
+
describe('formatActivityText', () => {
|
|
116
|
+
it('Read 工具 + file_path 参数生成正确描述', () => {
|
|
117
|
+
const result = formatActivityText('tool_use', 'Read', { file_path: '/src/utils/git.ts' });
|
|
118
|
+
expect(result).toBe('Read git.ts');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('Edit 工具 + file_path 参数生成正确描述', () => {
|
|
122
|
+
const result = formatActivityText('tool_use', 'Edit', { file_path: '/src/utils/progress.ts' });
|
|
123
|
+
expect(result).toBe('Edit progress.ts');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('Write 工具 + file_path 参数生成正确描述', () => {
|
|
127
|
+
const result = formatActivityText('tool_use', 'Write', { file_path: '/a/b/c.ts' });
|
|
128
|
+
expect(result).toBe('Write c.ts');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('Bash 工具追加 command 内容', () => {
|
|
132
|
+
const result = formatActivityText('tool_use', 'Bash', { command: 'ls -la' });
|
|
133
|
+
expect(result).toBe('Bash ls -la');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('Bash 工具长命令被截断', () => {
|
|
137
|
+
const result = formatActivityText('tool_use', 'Bash', { command: 'git log --oneline --graph --all --decorate' });
|
|
138
|
+
expect(result.length).toBeLessThanOrEqual(30);
|
|
139
|
+
expect(result.startsWith('Bash')).toBe(true);
|
|
140
|
+
expect(result.endsWith('…')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('Bash 工具 command 含换行时被清理', () => {
|
|
144
|
+
const result = formatActivityText('tool_use', 'Bash', { command: 'echo hello\n&& echo world' });
|
|
145
|
+
expect(result).not.toContain('\n');
|
|
146
|
+
expect(result).toContain('Bash');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('Glob 工具无 file_path 只显示工具名', () => {
|
|
150
|
+
const result = formatActivityText('tool_use', 'Glob', { pattern: '*.ts' });
|
|
151
|
+
expect(result).toBe('Glob');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('Grep 工具无 file_path 只显示工具名', () => {
|
|
155
|
+
const result = formatActivityText('tool_use', 'Grep', { pattern: 'TODO' });
|
|
156
|
+
expect(result).toBe('Grep');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('自定义工具直接显示英文名', () => {
|
|
160
|
+
const result = formatActivityText('tool_use', 'CustomTool', {});
|
|
161
|
+
expect(result).toBe('CustomTool');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('text 类型活动生成思考中描述', () => {
|
|
165
|
+
const result = formatActivityText('text', undefined, undefined, '让我分析一下');
|
|
166
|
+
expect(result).toBe('思考中: 让我分析一下');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('超长活动文本被截断到 30 字符', () => {
|
|
170
|
+
const result = formatActivityText('tool_use', 'Read', {
|
|
171
|
+
file_path: '/very/long/path/to/a/file/that/has/very/long/name/example.ts',
|
|
172
|
+
});
|
|
173
|
+
expect(result.length).toBeLessThanOrEqual(30);
|
|
174
|
+
expect(result.startsWith('Read')).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('text 包含换行和制表符时被清理', () => {
|
|
178
|
+
const result = formatActivityText('text', undefined, undefined, '行1\n行2\t行3');
|
|
179
|
+
expect(result).not.toContain('\n');
|
|
180
|
+
expect(result).not.toContain('\t');
|
|
181
|
+
expect(result).toContain('思考中');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('空文本返回空字符串', () => {
|
|
185
|
+
const result = formatActivityText('text', undefined, undefined, '');
|
|
186
|
+
expect(result).toBe('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('只有空白字符的文本返回空字符串', () => {
|
|
190
|
+
const result = formatActivityText('text', undefined, undefined, ' \n\t ');
|
|
191
|
+
expect(result).toBe('');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('无 toolName 时返回空字符串', () => {
|
|
195
|
+
const result = formatActivityText('tool_use');
|
|
196
|
+
expect(result).toBe('');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('无 file_path 的 Read 工具只显示工具名', () => {
|
|
200
|
+
const result = formatActivityText('tool_use', 'Read', {});
|
|
201
|
+
expect(result).toBe('Read');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ============================================================
|
|
206
|
+
// parseStreamEvent
|
|
207
|
+
// ============================================================
|
|
208
|
+
describe('parseStreamEvent', () => {
|
|
209
|
+
it('type=result 事件提取 ClaudeCodeResult', () => {
|
|
210
|
+
const event: StreamEvent = {
|
|
211
|
+
type: 'result',
|
|
212
|
+
subtype: 'success',
|
|
213
|
+
is_error: false,
|
|
214
|
+
duration_ms: 5000,
|
|
215
|
+
duration_api_ms: 4000,
|
|
216
|
+
num_turns: 3,
|
|
217
|
+
result: '任务完成',
|
|
218
|
+
stop_reason: 'end_turn',
|
|
219
|
+
session_id: 'sess-123',
|
|
220
|
+
total_cost_usd: 0.05,
|
|
221
|
+
usage: { input_tokens: 100, output_tokens: 200 },
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const parsed = parseStreamEvent(event);
|
|
225
|
+
expect(parsed).not.toBeNull();
|
|
226
|
+
expect(parsed!.kind).toBe('result');
|
|
227
|
+
expect(parsed!.activityText).toBe('');
|
|
228
|
+
expect(parsed!.result).toEqual({
|
|
229
|
+
type: 'result',
|
|
230
|
+
subtype: 'success',
|
|
231
|
+
is_error: false,
|
|
232
|
+
duration_ms: 5000,
|
|
233
|
+
duration_api_ms: 4000,
|
|
234
|
+
num_turns: 3,
|
|
235
|
+
result: '任务完成',
|
|
236
|
+
stop_reason: 'end_turn',
|
|
237
|
+
session_id: 'sess-123',
|
|
238
|
+
total_cost_usd: 0.05,
|
|
239
|
+
usage: { input_tokens: 100, output_tokens: 200 },
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('type=result 事件字段缺失时使用默认值', () => {
|
|
244
|
+
const event: StreamEvent = { type: 'result' };
|
|
245
|
+
const parsed = parseStreamEvent(event);
|
|
246
|
+
expect(parsed).not.toBeNull();
|
|
247
|
+
expect(parsed!.result!.is_error).toBe(false);
|
|
248
|
+
expect(parsed!.result!.duration_ms).toBe(0);
|
|
249
|
+
expect(parsed!.result!.total_cost_usd).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('type=assistant + tool_use 提取工具名和参数', () => {
|
|
253
|
+
const event: StreamEvent = {
|
|
254
|
+
type: 'assistant',
|
|
255
|
+
message: {
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: 'tool_use',
|
|
259
|
+
name: 'Read',
|
|
260
|
+
input: { file_path: '/src/index.ts' },
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const parsed = parseStreamEvent(event);
|
|
267
|
+
expect(parsed).not.toBeNull();
|
|
268
|
+
expect(parsed!.kind).toBe('tool_use');
|
|
269
|
+
expect(parsed!.activityText).toBe('Read index.ts');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('type=assistant + text 提取文本片段', () => {
|
|
273
|
+
const event: StreamEvent = {
|
|
274
|
+
type: 'assistant',
|
|
275
|
+
message: {
|
|
276
|
+
content: [
|
|
277
|
+
{
|
|
278
|
+
type: 'text',
|
|
279
|
+
text: '让我来分析这个问题',
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const parsed = parseStreamEvent(event);
|
|
286
|
+
expect(parsed).not.toBeNull();
|
|
287
|
+
expect(parsed!.kind).toBe('text');
|
|
288
|
+
expect(parsed!.activityText).toContain('思考中');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('同时有 tool_use 和 text 时优先返回 tool_use', () => {
|
|
292
|
+
const event: StreamEvent = {
|
|
293
|
+
type: 'assistant',
|
|
294
|
+
message: {
|
|
295
|
+
content: [
|
|
296
|
+
{ type: 'text', text: '我来看看' },
|
|
297
|
+
{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const parsed = parseStreamEvent(event);
|
|
303
|
+
expect(parsed).not.toBeNull();
|
|
304
|
+
expect(parsed!.kind).toBe('tool_use');
|
|
305
|
+
expect(parsed!.activityText).toBe('Bash ls');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('多个 tool_use 时取最后一个', () => {
|
|
309
|
+
const event: StreamEvent = {
|
|
310
|
+
type: 'assistant',
|
|
311
|
+
message: {
|
|
312
|
+
content: [
|
|
313
|
+
{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } },
|
|
314
|
+
{ type: 'tool_use', name: 'Edit', input: { file_path: '/b.ts' } },
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const parsed = parseStreamEvent(event);
|
|
320
|
+
expect(parsed).not.toBeNull();
|
|
321
|
+
expect(parsed!.activityText).toBe('Edit b.ts');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('type=user(tool_result)返回 null', () => {
|
|
325
|
+
const event: StreamEvent = {
|
|
326
|
+
type: 'user',
|
|
327
|
+
content: [
|
|
328
|
+
{ type: 'tool_result', content: '文件内容' },
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const parsed = parseStreamEvent(event);
|
|
333
|
+
expect(parsed).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('type=system 返回 null', () => {
|
|
337
|
+
const event: StreamEvent = {
|
|
338
|
+
type: 'system',
|
|
339
|
+
subtype: 'init',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const parsed = parseStreamEvent(event);
|
|
343
|
+
expect(parsed).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('content 为空数组时返回 null', () => {
|
|
347
|
+
const event: StreamEvent = {
|
|
348
|
+
type: 'assistant',
|
|
349
|
+
message: { content: [] },
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const parsed = parseStreamEvent(event);
|
|
353
|
+
expect(parsed).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('message 缺失时返回 null', () => {
|
|
357
|
+
const event: StreamEvent = {
|
|
358
|
+
type: 'assistant',
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const parsed = parseStreamEvent(event);
|
|
362
|
+
expect(parsed).toBeNull();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('message.content 缺失时返回 null', () => {
|
|
366
|
+
const event: StreamEvent = {
|
|
367
|
+
type: 'assistant',
|
|
368
|
+
message: {},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const parsed = parseStreamEvent(event);
|
|
372
|
+
expect(parsed).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|