chatccc 0.2.6 → 0.2.7
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/README.md +50 -11
- package/package.json +1 -1
- package/src/__tests__/adapter-interface.test.ts +151 -151
- package/src/__tests__/cards.test.ts +417 -413
- package/src/__tests__/claude-adapter.test.ts +528 -528
- package/src/__tests__/config.test.ts +123 -0
- package/src/__tests__/cursor-adapter.test.ts +662 -249
- package/src/__tests__/cursor-session-meta-store.test.ts +212 -0
- package/src/__tests__/fixtures/cursor_partial_only.jsonl +5 -0
- package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +13 -0
- package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +12 -0
- package/src/__tests__/git-command.test.ts +288 -0
- package/src/__tests__/session.test.ts +475 -296
- package/src/adapters/adapter-interface.ts +151 -126
- package/src/adapters/claude-adapter.ts +257 -257
- package/src/adapters/cursor-adapter.ts +401 -228
- package/src/adapters/cursor-session-meta-store.ts +154 -0
- package/src/cards.ts +33 -21
- package/src/config.ts +92 -2
- package/src/feishu-api.ts +1 -1
- package/src/git-command.ts +202 -0
- package/src/index.ts +58 -4
- package/src/session.ts +620 -513
|
@@ -1,250 +1,663 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
77
|
-
expect(result
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(result).
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
it("normalizes message with
|
|
117
|
-
const result = normalizeCursorMessage({
|
|
118
|
-
type: "assistant",
|
|
119
|
-
message: {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
expect(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
).
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
it("
|
|
174
|
-
const result = normalizeCursorMessage({
|
|
175
|
-
type: "assistant",
|
|
176
|
-
message: {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
expect(result).
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
expect(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
);
|
|
221
|
-
expect(result).
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { normalizeCursorMessage, createCursorAdapter } from "../adapters/cursor-adapter.ts";
|
|
6
|
+
import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
|
|
7
|
+
import type {
|
|
8
|
+
CursorSessionMeta,
|
|
9
|
+
CursorSessionMetaStore,
|
|
10
|
+
} from "../adapters/cursor-session-meta-store.ts";
|
|
11
|
+
import { accumulateBlockContent, pickFinalReply, type AccumulatorState } from "../session.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 进程内内存版 meta store,用于测试 getSessionInfo / 自动学习行为,避开磁盘 IO。
|
|
15
|
+
* 与文件实现行为一致:set 部分合并、空字段不覆盖。
|
|
16
|
+
*/
|
|
17
|
+
function createInMemoryMetaStore(
|
|
18
|
+
initial: Record<string, CursorSessionMeta> = {},
|
|
19
|
+
): CursorSessionMetaStore & { snapshot(): Record<string, CursorSessionMeta> } {
|
|
20
|
+
const map = new Map<string, CursorSessionMeta>(Object.entries(initial));
|
|
21
|
+
return {
|
|
22
|
+
async get(sid) {
|
|
23
|
+
return map.get(sid);
|
|
24
|
+
},
|
|
25
|
+
async set(sid, partial) {
|
|
26
|
+
const existing = map.get(sid);
|
|
27
|
+
const merged: { cwd?: string; model?: string } = { ...existing };
|
|
28
|
+
if (typeof partial.cwd === "string" && partial.cwd.length > 0) merged.cwd = partial.cwd;
|
|
29
|
+
if (typeof partial.model === "string" && partial.model.length > 0) merged.model = partial.model;
|
|
30
|
+
// cwd 缺失时记录视为不完整(与文件 store 一致:get 会返回 undefined)
|
|
31
|
+
if (!merged.cwd) return;
|
|
32
|
+
map.set(sid, merged.model ? { cwd: merged.cwd, model: merged.model } : { cwd: merged.cwd });
|
|
33
|
+
},
|
|
34
|
+
snapshot() {
|
|
35
|
+
return Object.fromEntries(map);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
|
|
42
|
+
function readFixture(name: string): unknown[] {
|
|
43
|
+
const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
|
|
44
|
+
return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// normalizeCursorMessage — 核心映射逻辑测试(纯函数)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe("normalizeCursorMessage", () => {
|
|
52
|
+
// --- assistant messages ---
|
|
53
|
+
|
|
54
|
+
it("normalizes assistant message with text block (partial 增量)", () => {
|
|
55
|
+
// 带 timestamp_ms ⇒ partial(增量片段)⇒ 映射为 text(追加语义)
|
|
56
|
+
const result = normalizeCursorMessage({
|
|
57
|
+
type: "assistant",
|
|
58
|
+
message: {
|
|
59
|
+
role: "assistant",
|
|
60
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
61
|
+
},
|
|
62
|
+
timestamp_ms: 1000,
|
|
63
|
+
});
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result!.type).toBe("assistant");
|
|
66
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "Hello world" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("normalizes assistant message with thinking block", () => {
|
|
70
|
+
const result = normalizeCursorMessage({
|
|
71
|
+
type: "assistant",
|
|
72
|
+
message: {
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: [{ type: "thinking", thinking: "Let me think..." }],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(result).not.toBeNull();
|
|
78
|
+
expect(result!.blocks).toEqual([{ type: "thinking", thinking: "Let me think..." }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("normalizes assistant message with tool_use block", () => {
|
|
82
|
+
const result = normalizeCursorMessage({
|
|
83
|
+
type: "assistant",
|
|
84
|
+
message: {
|
|
85
|
+
role: "assistant",
|
|
86
|
+
content: [
|
|
87
|
+
{ type: "tool_use", name: "read_file", input: { filePath: "/tmp/test" } },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
expect(result).not.toBeNull();
|
|
92
|
+
expect(result!.blocks).toEqual([
|
|
93
|
+
{ type: "tool_use", name: "read_file", input: { filePath: "/tmp/test" } },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses 'unknown' for tool_use without name", () => {
|
|
98
|
+
const result = normalizeCursorMessage({
|
|
99
|
+
type: "assistant",
|
|
100
|
+
message: { role: "assistant", content: [{ type: "tool_use", input: {} }] },
|
|
101
|
+
});
|
|
102
|
+
expect(result!.blocks[0]).toMatchObject({ type: "tool_use", name: "unknown" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("normalizes assistant message with search_result block", () => {
|
|
106
|
+
const result = normalizeCursorMessage({
|
|
107
|
+
type: "assistant",
|
|
108
|
+
message: {
|
|
109
|
+
role: "assistant",
|
|
110
|
+
content: [{ type: "search_result", query: "cursor docs" }],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect(result!.blocks).toEqual([{ type: "search_result", query: "cursor docs" }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("normalizes assistant message with redacted_thinking block", () => {
|
|
117
|
+
const result = normalizeCursorMessage({
|
|
118
|
+
type: "assistant",
|
|
119
|
+
message: { role: "assistant", content: [{ type: "redacted_thinking" }] },
|
|
120
|
+
});
|
|
121
|
+
expect(result!.blocks).toEqual([{ type: "redacted_thinking" }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// --- user messages (tool_result) ---
|
|
125
|
+
|
|
126
|
+
it("user 消息中的 text 被丢弃(避免用户输入 echo 污染最终回复)", () => {
|
|
127
|
+
const result = normalizeCursorMessage({
|
|
128
|
+
type: "user",
|
|
129
|
+
message: {
|
|
130
|
+
role: "user",
|
|
131
|
+
content: [{ type: "text", text: "原始用户输入回放" }],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result!.type).toBe("user");
|
|
136
|
+
expect(result!.blocks).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("normalizes user message with tool_result block (success)", () => {
|
|
140
|
+
const result = normalizeCursorMessage({
|
|
141
|
+
type: "user",
|
|
142
|
+
message: {
|
|
143
|
+
role: "user",
|
|
144
|
+
content: [
|
|
145
|
+
{ type: "tool_result", tool_use_id: "call_abc", content: "done", is_error: false },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(result).not.toBeNull();
|
|
150
|
+
expect(result!.type).toBe("user");
|
|
151
|
+
expect(result!.blocks).toEqual([
|
|
152
|
+
{ type: "tool_result", tool_use_id: "call_abc", content: "done", is_error: false },
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("normalizes user message with tool_result block (error)", () => {
|
|
157
|
+
const result = normalizeCursorMessage({
|
|
158
|
+
type: "user",
|
|
159
|
+
message: {
|
|
160
|
+
role: "user",
|
|
161
|
+
content: [
|
|
162
|
+
{ type: "tool_result", tool_use_id: "call_err", content: "fail", is_error: true },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
expect(result!.blocks).toEqual([
|
|
167
|
+
{ type: "tool_result", tool_use_id: "call_err", content: "fail", is_error: true },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- mixed blocks ---
|
|
172
|
+
|
|
173
|
+
it("normalizes message with multiple block types", () => {
|
|
174
|
+
const result = normalizeCursorMessage({
|
|
175
|
+
type: "assistant",
|
|
176
|
+
message: {
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: [
|
|
179
|
+
{ type: "thinking", thinking: "Hmm..." },
|
|
180
|
+
{ type: "tool_use", name: "search", input: { query: "x" } },
|
|
181
|
+
{ type: "text", text: "Result found." },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
timestamp_ms: 1000,
|
|
185
|
+
});
|
|
186
|
+
expect(result).not.toBeNull();
|
|
187
|
+
expect(result!.blocks).toHaveLength(3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// --- edge cases ---
|
|
191
|
+
|
|
192
|
+
// 注:result 消息映射为 text_final 的覆盖见下方 "result 消息" describe 块。
|
|
193
|
+
|
|
194
|
+
it("returns null for system init messages", () => {
|
|
195
|
+
expect(
|
|
196
|
+
normalizeCursorMessage({
|
|
197
|
+
type: "system",
|
|
198
|
+
subtype: "init",
|
|
199
|
+
session_id: "abc",
|
|
200
|
+
}),
|
|
201
|
+
).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns null for unknown message types", () => {
|
|
205
|
+
expect(normalizeCursorMessage({ type: "unknown_type" })).toBeNull();
|
|
206
|
+
expect(normalizeCursorMessage({})).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("skips unknown block types in content", () => {
|
|
210
|
+
const result = normalizeCursorMessage({
|
|
211
|
+
type: "assistant",
|
|
212
|
+
message: {
|
|
213
|
+
role: "assistant",
|
|
214
|
+
content: [
|
|
215
|
+
{ type: "custom_block", data: "ignored" },
|
|
216
|
+
{ type: "text", text: "visible" },
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
timestamp_ms: 1000,
|
|
220
|
+
});
|
|
221
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "visible" }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns message with empty blocks for empty content array", () => {
|
|
225
|
+
const result = normalizeCursorMessage({
|
|
226
|
+
type: "assistant",
|
|
227
|
+
message: { role: "assistant", content: [] },
|
|
228
|
+
});
|
|
229
|
+
expect(result).not.toBeNull();
|
|
230
|
+
expect(result!.blocks).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns null for message without content", () => {
|
|
234
|
+
const result = normalizeCursorMessage({
|
|
235
|
+
type: "assistant",
|
|
236
|
+
message: { role: "assistant" },
|
|
237
|
+
});
|
|
238
|
+
expect(result).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("skips thinking block with empty string", () => {
|
|
242
|
+
const result = normalizeCursorMessage({
|
|
243
|
+
type: "assistant",
|
|
244
|
+
message: { role: "assistant", content: [{ type: "thinking", thinking: "" }] },
|
|
245
|
+
});
|
|
246
|
+
expect(result!.blocks).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("skips text block with empty string", () => {
|
|
250
|
+
const result = normalizeCursorMessage({
|
|
251
|
+
type: "assistant",
|
|
252
|
+
message: { role: "assistant", content: [{ type: "text", text: "" }] },
|
|
253
|
+
});
|
|
254
|
+
expect(result!.blocks).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("returns null for system message without compact_boundary subtype", () => {
|
|
258
|
+
expect(
|
|
259
|
+
normalizeCursorMessage({ type: "system", subtype: "notification" }),
|
|
260
|
+
).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("normalizes system compact_boundary message", () => {
|
|
264
|
+
const msg: Record<string, unknown> = {
|
|
265
|
+
type: "system",
|
|
266
|
+
subtype: "compact_boundary",
|
|
267
|
+
compact_metadata: { trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
|
|
268
|
+
};
|
|
269
|
+
const result = normalizeCursorMessage(
|
|
270
|
+
msg as Parameters<typeof normalizeCursorMessage>[0],
|
|
271
|
+
);
|
|
272
|
+
expect(result).not.toBeNull();
|
|
273
|
+
expect(result!.type).toBe("system");
|
|
274
|
+
expect(result!.blocks).toEqual([
|
|
275
|
+
{ type: "compact_boundary", trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// createCursorAdapter — 工厂函数测试
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe("createCursorAdapter", () => {
|
|
285
|
+
it("returns adapter with correct displayName and sessionDescPrefix", () => {
|
|
286
|
+
const adapter = createCursorAdapter();
|
|
287
|
+
expect(adapter.displayName).toBe("Cursor");
|
|
288
|
+
expect(adapter.sessionDescPrefix).toBe("Cursor Session:");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("closeSession does not throw", async () => {
|
|
292
|
+
const adapter = createCursorAdapter();
|
|
293
|
+
await expect(adapter.closeSession("any-id")).resolves.toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// -------------------------------------------------------------------------
|
|
297
|
+
// getSessionInfo 行为契约
|
|
298
|
+
// - cwd 决定 /git 是否可用
|
|
299
|
+
// - model 决定 /status、/sessions 显示的是否是 Cursor 真实模型
|
|
300
|
+
// -------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
it("getSessionInfo: store 中无该 sessionId 时只返回 sessionId(cwd / model 都 undefined,让上层走错误分支)", async () => {
|
|
303
|
+
const store = createInMemoryMetaStore();
|
|
304
|
+
const adapter = createCursorAdapter({ metaStore: store });
|
|
305
|
+
const info = await adapter.getSessionInfo("unknown-sid");
|
|
306
|
+
expect(info).toEqual({ sessionId: "unknown-sid" });
|
|
307
|
+
expect(info?.cwd).toBeUndefined();
|
|
308
|
+
expect(info?.model).toBeUndefined();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("getSessionInfo: store 仅有 cwd 时返回 cwd,model 仍为 undefined(防止显示陈旧/错误模型)", async () => {
|
|
312
|
+
const store = createInMemoryMetaStore({ "sid-known": { cwd: "F:/proj/Foo" } });
|
|
313
|
+
const adapter = createCursorAdapter({ metaStore: store });
|
|
314
|
+
const info = await adapter.getSessionInfo("sid-known");
|
|
315
|
+
expect(info).toEqual({ sessionId: "sid-known", cwd: "F:/proj/Foo" });
|
|
316
|
+
expect(info?.model).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("getSessionInfo: store 同时有 cwd + model 时一并返回(这是 /status 显示真实模型的关键)", async () => {
|
|
320
|
+
const store = createInMemoryMetaStore({
|
|
321
|
+
"sid-known": { cwd: "F:/proj/Foo", model: "Composer 2 Fast" },
|
|
322
|
+
});
|
|
323
|
+
const adapter = createCursorAdapter({ metaStore: store });
|
|
324
|
+
const info = await adapter.getSessionInfo("sid-known");
|
|
325
|
+
expect(info).toEqual({
|
|
326
|
+
sessionId: "sid-known",
|
|
327
|
+
cwd: "F:/proj/Foo",
|
|
328
|
+
model: "Composer 2 Fast",
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("getSessionInfo: 不同 sessionId 互不影响", async () => {
|
|
333
|
+
const store = createInMemoryMetaStore({
|
|
334
|
+
"sid-A": { cwd: "/a", model: "mA" },
|
|
335
|
+
"sid-B": { cwd: "/b" },
|
|
336
|
+
});
|
|
337
|
+
const adapter = createCursorAdapter({ metaStore: store });
|
|
338
|
+
expect(await adapter.getSessionInfo("sid-A")).toEqual({
|
|
339
|
+
sessionId: "sid-A",
|
|
340
|
+
cwd: "/a",
|
|
341
|
+
model: "mA",
|
|
342
|
+
});
|
|
343
|
+
expect(await adapter.getSessionInfo("sid-B")).toEqual({
|
|
344
|
+
sessionId: "sid-B",
|
|
345
|
+
cwd: "/b",
|
|
346
|
+
});
|
|
347
|
+
expect(await adapter.getSessionInfo("sid-C")).toEqual({ sessionId: "sid-C" });
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// partial vs final 区分(防止最终消息重复出现两段)
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
//
|
|
355
|
+
// Cursor CLI 在 `--stream-partial-output` 模式下:
|
|
356
|
+
// - partial 消息带 `timestamp_ms` 字段,content 是 delta 增量
|
|
357
|
+
// - 流结束时再发一条 final 消息(无 `timestamp_ms`),content 是完整文本
|
|
358
|
+
// 若 normalize 时不区分这两种,accumulateBlockContent 会把所有 text 累加,
|
|
359
|
+
// 导致最终 finalText = (partial 拼接的完整) + (final 完整) ⇒ 同一段重复两次。
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Cursor stream-json 三类 assistant 事件 + result 消息的区分
|
|
364
|
+
//
|
|
365
|
+
// 官方文档:cursor.com/docs/cli/reference/output-format
|
|
366
|
+
// ┌───────────────────┬───────────────┬─────────────────┐
|
|
367
|
+
// │ 种类 │ timestamp_ms │ model_call_id │
|
|
368
|
+
// ├───────────────────┼───────────────┼─────────────────┤
|
|
369
|
+
// │ Streaming delta │ 有 │ 无 │ ← 唯一带新文本
|
|
370
|
+
// │ Buffered flush │ 有 │ 有 │ ← 重复(工具调用前快照)
|
|
371
|
+
// │ Final flush │ 无 │ 无 │ ← 重复(回合末快照)
|
|
372
|
+
// └───────────────────┴───────────────┴─────────────────┘
|
|
373
|
+
// 文档建议:取最终结果应直接读 result 消息的 result 字段。
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
|
|
377
|
+
it("Streaming delta(has timestamp_ms, no model_call_id)→ text(追加)", () => {
|
|
378
|
+
const result = normalizeCursorMessage({
|
|
379
|
+
type: "assistant",
|
|
380
|
+
message: { role: "assistant", content: [{ type: "text", text: "你" }] },
|
|
381
|
+
timestamp_ms: 1778411927583,
|
|
382
|
+
});
|
|
383
|
+
expect(result).not.toBeNull();
|
|
384
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("Buffered flush(has timestamp_ms, has model_call_id)→ text_final(覆盖,避免与 delta 重复累加)", () => {
|
|
388
|
+
const result = normalizeCursorMessage({
|
|
389
|
+
type: "assistant",
|
|
390
|
+
message: {
|
|
391
|
+
role: "assistant",
|
|
392
|
+
content: [{ type: "text", text: "完整快照(与 delta 累计相同)" }],
|
|
393
|
+
},
|
|
394
|
+
timestamp_ms: 1778411927999,
|
|
395
|
+
model_call_id: "mc-1",
|
|
396
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
397
|
+
expect(result).not.toBeNull();
|
|
398
|
+
expect(result!.blocks).toEqual([
|
|
399
|
+
{ type: "text_final", text: "完整快照(与 delta 累计相同)" },
|
|
400
|
+
]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("Final flush(no timestamp_ms)→ text_final(覆盖)", () => {
|
|
404
|
+
const result = normalizeCursorMessage({
|
|
405
|
+
type: "assistant",
|
|
406
|
+
message: {
|
|
407
|
+
role: "assistant",
|
|
408
|
+
content: [{ type: "text", text: "你上一题问的是 1+2=?" }],
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
expect(result).not.toBeNull();
|
|
412
|
+
expect(result!.blocks).toEqual([
|
|
413
|
+
{ type: "text_final", text: "你上一题问的是 1+2=?" },
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("normalizeCursorMessage - result 消息(官方权威最终文本)", () => {
|
|
419
|
+
it("result 消息提取 result 字段映射为 assistant + text_final", () => {
|
|
420
|
+
const result = normalizeCursorMessage({
|
|
421
|
+
type: "result",
|
|
422
|
+
subtype: "success",
|
|
423
|
+
result: "权威最终文本",
|
|
424
|
+
});
|
|
425
|
+
expect(result).not.toBeNull();
|
|
426
|
+
expect(result!.type).toBe("assistant");
|
|
427
|
+
expect(result!.blocks).toEqual([
|
|
428
|
+
{ type: "text_final", text: "权威最终文本" },
|
|
429
|
+
]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("result 消息没有 result 字段时返回 null(无可用文本)", () => {
|
|
433
|
+
expect(
|
|
434
|
+
normalizeCursorMessage({ type: "result", subtype: "error" }),
|
|
435
|
+
).toBeNull();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("result 消息 result 字段为空字符串时返回 null", () => {
|
|
439
|
+
expect(
|
|
440
|
+
normalizeCursorMessage({ type: "result", subtype: "success", result: "" }),
|
|
441
|
+
).toBeNull();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe("Cursor stream fixture - 端到端不重复", () => {
|
|
446
|
+
it("partial+final 流结束后最终回复只包含一段完整文本(不重复)", () => {
|
|
447
|
+
const lines = readFixture("cursor_partial_with_final.jsonl");
|
|
448
|
+
const state: AccumulatorState = {
|
|
449
|
+
accumulatedContent: "",
|
|
450
|
+
finalText: "",
|
|
451
|
+
finalCompleteText: "",
|
|
452
|
+
chunkCount: 0,
|
|
453
|
+
};
|
|
454
|
+
for (const raw of lines) {
|
|
455
|
+
const normalized = normalizeCursorMessage(
|
|
456
|
+
raw as Parameters<typeof normalizeCursorMessage>[0],
|
|
457
|
+
);
|
|
458
|
+
if (!normalized) continue;
|
|
459
|
+
for (const block of normalized.blocks) {
|
|
460
|
+
accumulateBlockContent(block, state);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const expected = "你上一题问的是 **1+2=?**(一加二等于几)。";
|
|
465
|
+
const reply = pickFinalReply(state);
|
|
466
|
+
expect(reply).toBe(expected);
|
|
467
|
+
// 关键断言:长度等于完整文本,不应翻倍
|
|
468
|
+
expect(reply.length).toBe(expected.length);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("仅 partial 无 final 时,回复 = partial 累加结果", () => {
|
|
472
|
+
const lines = readFixture("cursor_partial_only.jsonl");
|
|
473
|
+
const state: AccumulatorState = {
|
|
474
|
+
accumulatedContent: "",
|
|
475
|
+
finalText: "",
|
|
476
|
+
finalCompleteText: "",
|
|
477
|
+
chunkCount: 0,
|
|
478
|
+
};
|
|
479
|
+
for (const raw of lines) {
|
|
480
|
+
const normalized = normalizeCursorMessage(
|
|
481
|
+
raw as Parameters<typeof normalizeCursorMessage>[0],
|
|
482
|
+
);
|
|
483
|
+
if (!normalized) continue;
|
|
484
|
+
for (const block of normalized.blocks) {
|
|
485
|
+
accumulateBlockContent(block, state);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 注:fixture 末尾有 result 消息,按官方语义其 result 字段是权威最终文本
|
|
490
|
+
expect(pickFinalReply(state)).toBe("hello world");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("有工具调用回合(含 buffered flush)最终回复仍取 result.result,无重复", () => {
|
|
494
|
+
const lines = readFixture("cursor_with_tool_call.jsonl");
|
|
495
|
+
const state: AccumulatorState = {
|
|
496
|
+
accumulatedContent: "",
|
|
497
|
+
finalText: "",
|
|
498
|
+
finalCompleteText: "",
|
|
499
|
+
chunkCount: 0,
|
|
500
|
+
};
|
|
501
|
+
for (const raw of lines) {
|
|
502
|
+
const normalized = normalizeCursorMessage(
|
|
503
|
+
raw as Parameters<typeof normalizeCursorMessage>[0],
|
|
504
|
+
);
|
|
505
|
+
if (!normalized) continue;
|
|
506
|
+
for (const block of normalized.blocks) {
|
|
507
|
+
accumulateBlockContent(block, state);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const expected = "我看下。两个文件。";
|
|
512
|
+
const reply = pickFinalReply(state);
|
|
513
|
+
expect(reply).toBe(expected);
|
|
514
|
+
// 关键断言:buffered flush 不会让回复变长(不重复)
|
|
515
|
+
expect(reply.length).toBe(expected.length);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("buffered flush 之后的新增量文本不会被 pickFinalReply 吞掉", () => {
|
|
519
|
+
// 模拟流中间状态:buffered flush 设置了 finalCompleteText,然后新的
|
|
520
|
+
// 增量 text 到达。之前有个 bug 是 pickFinalReply 一直返回旧的
|
|
521
|
+
// finalCompleteText,导致工具调用后的新文本在卡片中不可见。
|
|
522
|
+
const state: AccumulatorState = {
|
|
523
|
+
accumulatedContent: "",
|
|
524
|
+
finalText: "",
|
|
525
|
+
finalCompleteText: "",
|
|
526
|
+
chunkCount: 0,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// 1) 工具调用前的增量(同 cursor_with_tool_call.jsonl lines 3-5)
|
|
530
|
+
accumulateBlockContent({ type: "text", text: "我" }, state);
|
|
531
|
+
accumulateBlockContent({ type: "text", text: "看下" }, state);
|
|
532
|
+
accumulateBlockContent({ type: "text", text: "。" }, state);
|
|
533
|
+
expect(state.finalText).toBe("我看下。");
|
|
534
|
+
|
|
535
|
+
// 2) buffered flush 快照(含 tool_use,line 6)
|
|
536
|
+
accumulateBlockContent({ type: "text_final", text: "我看下。" }, state);
|
|
537
|
+
accumulateBlockContent({ type: "tool_use", name: "ls", input: { path: "." } }, state);
|
|
538
|
+
expect(state.finalCompleteText).toBe("我看下。");
|
|
539
|
+
// 此时 finalText 已经包含了与 flush 相同的文本
|
|
540
|
+
expect(pickFinalReply(state)).toBe("我看下。");
|
|
541
|
+
|
|
542
|
+
// 3) tool_result(line 7)
|
|
543
|
+
accumulateBlockContent({ type: "tool_result", tool_use_id: "tool-1", content: "file1\nfile2", is_error: false }, state);
|
|
544
|
+
|
|
545
|
+
// 4) 工具调用后的新增量(lines 8-9):关键断言
|
|
546
|
+
accumulateBlockContent({ type: "text", text: "两" }, state);
|
|
547
|
+
// 新的增量 text 应清空 finalCompleteText,pickFinalReply 回退到 finalText
|
|
548
|
+
expect(state.finalCompleteText).toBe("");
|
|
549
|
+
expect(state.finalText).toBe("我看下。两");
|
|
550
|
+
expect(pickFinalReply(state)).toBe("我看下。两");
|
|
551
|
+
|
|
552
|
+
accumulateBlockContent({ type: "text", text: "个文件" }, state);
|
|
553
|
+
expect(pickFinalReply(state)).toBe("我看下。两个文件");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// -------------------------------------------------------------------------
|
|
557
|
+
// 新格式:独立的 thinking 和 tool_call 消息(Cursor agent 实际发出的格式)
|
|
558
|
+
// -------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
it("normalizes independent thinking delta message", () => {
|
|
561
|
+
const result = normalizeCursorMessage({
|
|
562
|
+
type: "thinking",
|
|
563
|
+
subtype: "delta",
|
|
564
|
+
text: " Let me think...",
|
|
565
|
+
session_id: "sid",
|
|
566
|
+
timestamp_ms: 1000,
|
|
567
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
568
|
+
expect(result).not.toBeNull();
|
|
569
|
+
expect(result!.blocks).toEqual([
|
|
570
|
+
{ type: "thinking", thinking: " Let me think..." },
|
|
571
|
+
]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("ignores thinking completed message", () => {
|
|
575
|
+
const result = normalizeCursorMessage({
|
|
576
|
+
type: "thinking",
|
|
577
|
+
subtype: "completed",
|
|
578
|
+
session_id: "sid",
|
|
579
|
+
});
|
|
580
|
+
expect(result).toBeNull();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("normalizes tool_call started → tool_use block", () => {
|
|
584
|
+
const result = normalizeCursorMessage({
|
|
585
|
+
type: "tool_call",
|
|
586
|
+
subtype: "started",
|
|
587
|
+
call_id: "toolu_abc",
|
|
588
|
+
tool_call: {
|
|
589
|
+
shellToolCall: {
|
|
590
|
+
args: { command: "ls" },
|
|
591
|
+
description: "List files",
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
session_id: "sid",
|
|
595
|
+
timestamp_ms: 1000,
|
|
596
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
597
|
+
expect(result).not.toBeNull();
|
|
598
|
+
expect(result!.blocks).toEqual([
|
|
599
|
+
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
|
600
|
+
]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("normalizes tool_call completed → tool_result block (success)", () => {
|
|
604
|
+
const result = normalizeCursorMessage({
|
|
605
|
+
type: "tool_call",
|
|
606
|
+
subtype: "completed",
|
|
607
|
+
call_id: "toolu_abc",
|
|
608
|
+
tool_call: {
|
|
609
|
+
shellToolCall: {
|
|
610
|
+
args: { command: "ls" },
|
|
611
|
+
result: { success: { stdout: "file1\nfile2" } },
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
session_id: "sid",
|
|
615
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
616
|
+
expect(result).not.toBeNull();
|
|
617
|
+
expect(result!.blocks[0]).toMatchObject({
|
|
618
|
+
type: "tool_result",
|
|
619
|
+
tool_use_id: "toolu_abc",
|
|
620
|
+
content: "file1\nfile2",
|
|
621
|
+
is_error: undefined,
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("normalizes tool_call completed → tool_result block (error)", () => {
|
|
626
|
+
const result = normalizeCursorMessage({
|
|
627
|
+
type: "tool_call",
|
|
628
|
+
subtype: "completed",
|
|
629
|
+
call_id: "toolu_err",
|
|
630
|
+
tool_call: {
|
|
631
|
+
readToolCall: {
|
|
632
|
+
args: { filePath: "/nonexistent" },
|
|
633
|
+
result: { error: "file not found" },
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
637
|
+
expect(result).not.toBeNull();
|
|
638
|
+
expect(result!.blocks[0]).toMatchObject({
|
|
639
|
+
type: "tool_result",
|
|
640
|
+
tool_use_id: "toolu_err",
|
|
641
|
+
is_error: true,
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("mapToolCallKey: maps known keys to readable names", () => {
|
|
646
|
+
const cases: [string, string][] = [
|
|
647
|
+
["globToolCall", "Glob"],
|
|
648
|
+
["shellToolCall", "Bash"],
|
|
649
|
+
["readToolCall", "Read"],
|
|
650
|
+
["grepToolCall", "Grep"],
|
|
651
|
+
["unknownCall", "unknownCall"],
|
|
652
|
+
];
|
|
653
|
+
for (const [raw, expected] of cases) {
|
|
654
|
+
const r = normalizeCursorMessage({
|
|
655
|
+
type: "tool_call",
|
|
656
|
+
subtype: "started",
|
|
657
|
+
call_id: "id",
|
|
658
|
+
tool_call: { [raw]: { args: {} } },
|
|
659
|
+
} as Parameters<typeof normalizeCursorMessage>[0]);
|
|
660
|
+
expect(r!.blocks[0]).toMatchObject({ type: "tool_use", name: expected });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
250
663
|
});
|