chatccc 0.2.4 → 0.2.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/package.json +1 -1
- package/src/__tests__/adapter-interface.test.ts +152 -0
- package/src/__tests__/claude-adapter.test.ts +529 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/adapters/adapter-interface.ts +127 -0
- package/src/adapters/claude-adapter.ts +257 -0
- package/src/config.ts +4 -1
- package/src/index.ts +7 -4
- package/src/session.ts +450 -455
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
UnifiedBlock,
|
|
4
|
+
UnifiedStreamMessage,
|
|
5
|
+
UnifiedThinkingBlock,
|
|
6
|
+
UnifiedTextBlock,
|
|
7
|
+
UnifiedToolUseBlock,
|
|
8
|
+
UnifiedToolResultBlock,
|
|
9
|
+
UnifiedRedactedThinkingBlock,
|
|
10
|
+
UnifiedSearchResultBlock,
|
|
11
|
+
UnifiedCompactBoundaryBlock,
|
|
12
|
+
CreateSessionResult,
|
|
13
|
+
SessionInfo,
|
|
14
|
+
ToolAdapter,
|
|
15
|
+
} from "../adapters/adapter-interface.ts";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// 编译期类型验证:确保 UnifiedBlock 联合类型覆盖了所有预期的 block 形状
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
describe("UnifiedBlock union type coverage", () => {
|
|
22
|
+
it("accepts thinking block", () => {
|
|
23
|
+
const b: UnifiedBlock = { type: "thinking", thinking: "Let me think..." };
|
|
24
|
+
expect(b.type).toBe("thinking");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts text block", () => {
|
|
28
|
+
const b: UnifiedBlock = { type: "text", text: "Hello" };
|
|
29
|
+
expect(b.type).toBe("text");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("accepts tool_use block", () => {
|
|
33
|
+
const b: UnifiedBlock = { type: "tool_use", name: "Read", input: { file_path: "/tmp" } };
|
|
34
|
+
expect(b.type).toBe("tool_use");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts tool_result block with string content", () => {
|
|
38
|
+
const b: UnifiedBlock = { type: "tool_result", tool_use_id: "abc123", content: "result" };
|
|
39
|
+
expect(b.type).toBe("tool_result");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts tool_result block with error flag", () => {
|
|
43
|
+
const b: UnifiedBlock = { type: "tool_result", tool_use_id: "abc123", content: "error", is_error: true };
|
|
44
|
+
expect(b.is_error).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts redacted_thinking block", () => {
|
|
48
|
+
const b: UnifiedBlock = { type: "redacted_thinking" };
|
|
49
|
+
expect(b.type).toBe("redacted_thinking");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts search_result block", () => {
|
|
53
|
+
const b: UnifiedBlock = { type: "search_result", query: "what is TypeScript" };
|
|
54
|
+
expect(b.type).toBe("search_result");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("accepts compact_boundary block", () => {
|
|
58
|
+
const b: UnifiedBlock = { type: "compact_boundary", trigger: "auto", pre_tokens: 10000, post_tokens: 5000 };
|
|
59
|
+
expect(b.type).toBe("compact_boundary");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("accepts compact_boundary block without post_tokens", () => {
|
|
63
|
+
const b: UnifiedBlock = { type: "compact_boundary", trigger: "manual", pre_tokens: 20000 };
|
|
64
|
+
expect(b.type).toBe("compact_boundary");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// UnifiedStreamMessage
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
describe("UnifiedStreamMessage", () => {
|
|
73
|
+
it("accepts assistant message with blocks", () => {
|
|
74
|
+
const m: UnifiedStreamMessage = {
|
|
75
|
+
type: "assistant",
|
|
76
|
+
blocks: [
|
|
77
|
+
{ type: "thinking", thinking: "..." },
|
|
78
|
+
{ type: "text", text: "answer" },
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
expect(m.type).toBe("assistant");
|
|
82
|
+
expect(m.blocks).toHaveLength(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("accepts user message with tool_result blocks", () => {
|
|
86
|
+
const m: UnifiedStreamMessage = {
|
|
87
|
+
type: "user",
|
|
88
|
+
blocks: [{ type: "tool_result", tool_use_id: "abc", content: "ok" }],
|
|
89
|
+
};
|
|
90
|
+
expect(m.type).toBe("user");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("accepts system message", () => {
|
|
94
|
+
const m: UnifiedStreamMessage = {
|
|
95
|
+
type: "system",
|
|
96
|
+
blocks: [{ type: "compact_boundary", trigger: "auto", pre_tokens: 100 }],
|
|
97
|
+
};
|
|
98
|
+
expect(m.type).toBe("system");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// CreateSessionResult
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
describe("CreateSessionResult", () => {
|
|
107
|
+
it("accepts valid result", () => {
|
|
108
|
+
const r: CreateSessionResult = { sessionId: "uuid-12345" };
|
|
109
|
+
expect(r.sessionId).toBe("uuid-12345");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// SessionInfo
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("SessionInfo", () => {
|
|
118
|
+
it("accepts full info", () => {
|
|
119
|
+
const info: SessionInfo = {
|
|
120
|
+
sessionId: "abc",
|
|
121
|
+
cwd: "/home/user",
|
|
122
|
+
summary: "A session",
|
|
123
|
+
lastModified: 1234567890,
|
|
124
|
+
};
|
|
125
|
+
expect(info.sessionId).toBe("abc");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("accepts minimal info (only sessionId)", () => {
|
|
129
|
+
const info: SessionInfo = { sessionId: "minimal" };
|
|
130
|
+
expect(info.cwd).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// ToolAdapter interface 结构断言
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe("ToolAdapter interface structure", () => {
|
|
139
|
+
it("has correct property names via mock object", () => {
|
|
140
|
+
// 如果 ToolAdapter 接口的字段名或函数签名变更,该对象字面量将无法编译
|
|
141
|
+
const mock: ToolAdapter = {
|
|
142
|
+
displayName: "Test",
|
|
143
|
+
sessionDescPrefix: "Test Session:",
|
|
144
|
+
createSession: async () => ({ sessionId: "s" }),
|
|
145
|
+
prompt: async function* () {},
|
|
146
|
+
getSessionInfo: async () => undefined,
|
|
147
|
+
closeSession: async () => {},
|
|
148
|
+
};
|
|
149
|
+
expect(mock.displayName).toBe("Test");
|
|
150
|
+
expect(mock.sessionDescPrefix).toBe("Test Session:");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { normalizeSdkMessage, createClaudeAdapter } from "../adapters/claude-adapter.ts";
|
|
3
|
+
import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock Claude SDK
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const mockSend = vi.fn();
|
|
10
|
+
const mockClose = vi.fn();
|
|
11
|
+
const mockStreamNext = vi.fn();
|
|
12
|
+
const mockStreamIterable: AsyncGenerator<any, void> = {
|
|
13
|
+
next: mockStreamNext,
|
|
14
|
+
[Symbol.asyncIterator]() { return this; },
|
|
15
|
+
} as any;
|
|
16
|
+
|
|
17
|
+
function mockSession(overrides: Partial<{ send: any; close: any; streamNext: any }> = {}) {
|
|
18
|
+
return {
|
|
19
|
+
send: overrides.send ?? mockSend,
|
|
20
|
+
close: overrides.close ?? mockClose,
|
|
21
|
+
stream: () => ({
|
|
22
|
+
next: overrides.streamNext ?? mockStreamNext,
|
|
23
|
+
[Symbol.asyncIterator]() { return this; },
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => {
|
|
29
|
+
// We only mock the full adapter integration tests; normalizeSdkMessage is pure
|
|
30
|
+
return {
|
|
31
|
+
unstable_v2_createSession: vi.fn(),
|
|
32
|
+
unstable_v2_resumeSession: vi.fn(),
|
|
33
|
+
getSessionInfo: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// normalizeSdkMessage — 核心映射逻辑测试(纯函数,不需要 mock SDK)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
describe("normalizeSdkMessage", () => {
|
|
42
|
+
// --- assistant messages ---
|
|
43
|
+
|
|
44
|
+
it("normalizes assistant message with text block", () => {
|
|
45
|
+
const result = normalizeSdkMessage({
|
|
46
|
+
type: "assistant",
|
|
47
|
+
message: { content: [{ type: "text", text: "Hello world" }] },
|
|
48
|
+
});
|
|
49
|
+
expect(result).not.toBeNull();
|
|
50
|
+
expect(result!.type).toBe("assistant");
|
|
51
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "Hello world" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("normalizes assistant message with thinking block", () => {
|
|
55
|
+
const result = normalizeSdkMessage({
|
|
56
|
+
type: "assistant",
|
|
57
|
+
message: { content: [{ type: "thinking", thinking: "Let me analyze..." }] },
|
|
58
|
+
});
|
|
59
|
+
expect(result).not.toBeNull();
|
|
60
|
+
expect(result!.blocks).toEqual([{ type: "thinking", thinking: "Let me analyze..." }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("normalizes assistant message with tool_use block", () => {
|
|
64
|
+
const result = normalizeSdkMessage({
|
|
65
|
+
type: "assistant",
|
|
66
|
+
message: {
|
|
67
|
+
content: [{ type: "tool_use", name: "Read", input: { file_path: "/tmp/test.txt" } }],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(result).not.toBeNull();
|
|
71
|
+
expect(result!.blocks).toEqual([
|
|
72
|
+
{ type: "tool_use", name: "Read", input: { file_path: "/tmp/test.txt" } },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('uses "unknown" for tool_use without name', () => {
|
|
77
|
+
const result = normalizeSdkMessage({
|
|
78
|
+
type: "assistant",
|
|
79
|
+
message: { content: [{ type: "tool_use", input: { x: 1 } }] },
|
|
80
|
+
});
|
|
81
|
+
expect(result!.blocks[0]).toMatchObject({ type: "tool_use", name: "unknown" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("normalizes user message with tool_result block (success)", () => {
|
|
85
|
+
const result = normalizeSdkMessage({
|
|
86
|
+
type: "user",
|
|
87
|
+
message: {
|
|
88
|
+
content: [
|
|
89
|
+
{ type: "tool_result", tool_use_id: "abc123", content: "file content here", is_error: false },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(result).not.toBeNull();
|
|
94
|
+
expect(result!.type).toBe("user");
|
|
95
|
+
const block = result!.blocks[0] as any;
|
|
96
|
+
expect(block.type).toBe("tool_result");
|
|
97
|
+
expect(block.tool_use_id).toBe("abc123");
|
|
98
|
+
expect(block.content).toBe("file content here");
|
|
99
|
+
expect(block.is_error).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("normalizes user message with tool_result block (error)", () => {
|
|
103
|
+
const result = normalizeSdkMessage({
|
|
104
|
+
type: "user",
|
|
105
|
+
message: {
|
|
106
|
+
content: [
|
|
107
|
+
{ type: "tool_result", tool_use_id: "err456", content: "permission denied", is_error: true },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(result!.blocks[0]).toMatchObject({
|
|
112
|
+
type: "tool_result",
|
|
113
|
+
tool_use_id: "err456",
|
|
114
|
+
is_error: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("normalizes tool_result with array content", () => {
|
|
119
|
+
const content = [{ type: "text", text: "line 1" }, { type: "text", text: "line 2" }];
|
|
120
|
+
const result = normalizeSdkMessage({
|
|
121
|
+
type: "user",
|
|
122
|
+
message: { content: [{ type: "tool_result", tool_use_id: "arr", content }] },
|
|
123
|
+
});
|
|
124
|
+
expect(result!.blocks[0]).toMatchObject({
|
|
125
|
+
type: "tool_result",
|
|
126
|
+
tool_use_id: "arr",
|
|
127
|
+
});
|
|
128
|
+
expect((result!.blocks[0] as any).content).toEqual(content);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("normalizes redacted_thinking block", () => {
|
|
132
|
+
const result = normalizeSdkMessage({
|
|
133
|
+
type: "assistant",
|
|
134
|
+
message: { content: [{ type: "redacted_thinking" }] },
|
|
135
|
+
});
|
|
136
|
+
expect(result!.blocks).toEqual([{ type: "redacted_thinking" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("normalizes search_result block", () => {
|
|
140
|
+
const result = normalizeSdkMessage({
|
|
141
|
+
type: "assistant",
|
|
142
|
+
message: {
|
|
143
|
+
content: [{ type: "search_result", query: "TypeScript best practices" }],
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(result!.blocks).toEqual([
|
|
147
|
+
{ type: "search_result", query: "TypeScript best practices" },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("normalizes search_result without query (empty string)", () => {
|
|
152
|
+
const result = normalizeSdkMessage({
|
|
153
|
+
type: "assistant",
|
|
154
|
+
message: { content: [{ type: "search_result" }] },
|
|
155
|
+
});
|
|
156
|
+
expect(result!.blocks).toEqual([{ type: "search_result", query: "" }]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- system messages ---
|
|
160
|
+
|
|
161
|
+
it("normalizes system compact_boundary message", () => {
|
|
162
|
+
const result = normalizeSdkMessage({
|
|
163
|
+
type: "system",
|
|
164
|
+
subtype: "compact_boundary",
|
|
165
|
+
compact_metadata: {
|
|
166
|
+
trigger: "auto",
|
|
167
|
+
pre_tokens: 15000,
|
|
168
|
+
post_tokens: 8000,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(result).not.toBeNull();
|
|
172
|
+
expect(result!.type).toBe("system");
|
|
173
|
+
expect(result!.blocks).toEqual([
|
|
174
|
+
{ type: "compact_boundary", trigger: "auto", pre_tokens: 15000, post_tokens: 8000 },
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("normalizes compact_boundary with manual trigger", () => {
|
|
179
|
+
const result = normalizeSdkMessage({
|
|
180
|
+
type: "system",
|
|
181
|
+
subtype: "compact_boundary",
|
|
182
|
+
compact_metadata: { trigger: "manual", pre_tokens: 20000 },
|
|
183
|
+
});
|
|
184
|
+
expect(result!.blocks).toEqual([
|
|
185
|
+
{ type: "compact_boundary", trigger: "manual", pre_tokens: 20000, post_tokens: undefined },
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("normalizes compact_boundary without post_tokens", () => {
|
|
190
|
+
const result = normalizeSdkMessage({
|
|
191
|
+
type: "system",
|
|
192
|
+
subtype: "compact_boundary",
|
|
193
|
+
compact_metadata: { trigger: "auto", pre_tokens: 10000 },
|
|
194
|
+
});
|
|
195
|
+
expect(result!.blocks).toEqual([
|
|
196
|
+
{ type: "compact_boundary", trigger: "auto", pre_tokens: 10000, post_tokens: undefined },
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- mixed blocks ---
|
|
201
|
+
|
|
202
|
+
it("normalizes message with multiple block types mixed", () => {
|
|
203
|
+
const result = normalizeSdkMessage({
|
|
204
|
+
type: "assistant",
|
|
205
|
+
message: {
|
|
206
|
+
content: [
|
|
207
|
+
{ type: "thinking", thinking: "Hmm..." },
|
|
208
|
+
{ type: "tool_use", name: "Grep", input: { pattern: "foo" } },
|
|
209
|
+
{ type: "text", text: "Found it." },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
expect(result).not.toBeNull();
|
|
214
|
+
expect(result!.blocks).toHaveLength(3);
|
|
215
|
+
expect(result!.blocks[0]).toEqual({ type: "thinking", thinking: "Hmm..." });
|
|
216
|
+
expect(result!.blocks[1]).toEqual({ type: "tool_use", name: "Grep", input: { pattern: "foo" } });
|
|
217
|
+
expect(result!.blocks[2]).toEqual({ type: "text", text: "Found it." });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- edge cases ---
|
|
221
|
+
|
|
222
|
+
it("skips unknown block types", () => {
|
|
223
|
+
const result = normalizeSdkMessage({
|
|
224
|
+
type: "assistant",
|
|
225
|
+
message: {
|
|
226
|
+
content: [
|
|
227
|
+
{ type: "unknown_type", data: "whatever" },
|
|
228
|
+
{ type: "text", text: "still here" },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
expect(result!.blocks).toHaveLength(1);
|
|
233
|
+
expect(result!.blocks[0]).toEqual({ type: "text", text: "still here" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("returns null for non-assistant/non-user/non-system messages", () => {
|
|
237
|
+
expect(
|
|
238
|
+
normalizeSdkMessage({ type: "result", subtype: "success" }),
|
|
239
|
+
).toBeNull();
|
|
240
|
+
expect(
|
|
241
|
+
normalizeSdkMessage({ type: "stream_event" }),
|
|
242
|
+
).toBeNull();
|
|
243
|
+
expect(
|
|
244
|
+
normalizeSdkMessage({ type: "status" }),
|
|
245
|
+
).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns null for system message without compact_boundary subtype", () => {
|
|
249
|
+
expect(
|
|
250
|
+
normalizeSdkMessage({ type: "system", subtype: "init" }),
|
|
251
|
+
).toBeNull();
|
|
252
|
+
expect(
|
|
253
|
+
normalizeSdkMessage({ type: "system", subtype: "notification" }),
|
|
254
|
+
).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("returns message with empty blocks for content=[ ]", () => {
|
|
258
|
+
const result = normalizeSdkMessage({
|
|
259
|
+
type: "assistant",
|
|
260
|
+
message: { content: [] },
|
|
261
|
+
});
|
|
262
|
+
expect(result).not.toBeNull();
|
|
263
|
+
expect(result!.blocks).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("returns null for message without content", () => {
|
|
267
|
+
const result = normalizeSdkMessage({
|
|
268
|
+
type: "assistant",
|
|
269
|
+
message: {},
|
|
270
|
+
});
|
|
271
|
+
expect(result).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns null for message with null content array", () => {
|
|
275
|
+
const result = normalizeSdkMessage({
|
|
276
|
+
type: "assistant",
|
|
277
|
+
message: { content: undefined as any },
|
|
278
|
+
});
|
|
279
|
+
expect(result).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns null for compact_boundary without metadata", () => {
|
|
283
|
+
const result = normalizeSdkMessage({
|
|
284
|
+
type: "system",
|
|
285
|
+
subtype: "compact_boundary",
|
|
286
|
+
});
|
|
287
|
+
expect(result).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("skips thinking block with empty thinking string", () => {
|
|
291
|
+
const result = normalizeSdkMessage({
|
|
292
|
+
type: "assistant",
|
|
293
|
+
message: { content: [{ type: "thinking", thinking: "" }] },
|
|
294
|
+
});
|
|
295
|
+
// empty thinking string → falsy check (block.thinking → "") → skipped
|
|
296
|
+
expect(result!.blocks).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("skips text block with empty text string", () => {
|
|
300
|
+
const result = normalizeSdkMessage({
|
|
301
|
+
type: "assistant",
|
|
302
|
+
message: { content: [{ type: "text", text: "" }] },
|
|
303
|
+
});
|
|
304
|
+
// empty text string → falsy check → skipped
|
|
305
|
+
expect(result!.blocks).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("handles message with undefined type gracefully", () => {
|
|
309
|
+
const result = normalizeSdkMessage({
|
|
310
|
+
message: { content: [{ type: "text", text: "orphan" }] },
|
|
311
|
+
});
|
|
312
|
+
expect(result).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// createClaudeAdapter — 集成测试(mock SDK)
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
describe("createClaudeAdapter", () => {
|
|
321
|
+
let sdk: any;
|
|
322
|
+
|
|
323
|
+
beforeEach(async () => {
|
|
324
|
+
vi.clearAllMocks();
|
|
325
|
+
sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns adapter with correct displayName and sessionDescPrefix", () => {
|
|
329
|
+
const adapter = createClaudeAdapter({
|
|
330
|
+
model: "claude-sonnet-4-6",
|
|
331
|
+
effort: "high",
|
|
332
|
+
isDefault: (v) => v.trim().toLowerCase() === "default",
|
|
333
|
+
});
|
|
334
|
+
expect(adapter.displayName).toBe("Claude");
|
|
335
|
+
expect(adapter.sessionDescPrefix).toBe("Claude Session:");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("createSession returns sessionId from init event", async () => {
|
|
339
|
+
const mockSessionObj = mockSession({
|
|
340
|
+
streamNext: vi
|
|
341
|
+
.fn()
|
|
342
|
+
.mockResolvedValueOnce({
|
|
343
|
+
done: false,
|
|
344
|
+
value: { session_id: "test-sid-001", type: "system", subtype: "init" },
|
|
345
|
+
})
|
|
346
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
347
|
+
});
|
|
348
|
+
sdk.unstable_v2_createSession.mockReturnValue(mockSessionObj);
|
|
349
|
+
|
|
350
|
+
const adapter = createClaudeAdapter({
|
|
351
|
+
model: "claude-sonnet-4-6",
|
|
352
|
+
effort: "high",
|
|
353
|
+
isDefault: () => false,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const result = await adapter.createSession("/tmp");
|
|
357
|
+
expect(result.sessionId).toBe("test-sid-001");
|
|
358
|
+
expect(sdk.unstable_v2_createSession).toHaveBeenCalledTimes(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("createSession throws when first event has no session_id", async () => {
|
|
362
|
+
const mockSessionObj = mockSession({
|
|
363
|
+
streamNext: vi
|
|
364
|
+
.fn()
|
|
365
|
+
.mockResolvedValueOnce({
|
|
366
|
+
done: false,
|
|
367
|
+
value: { type: "other", no_session_id: true },
|
|
368
|
+
}),
|
|
369
|
+
});
|
|
370
|
+
sdk.unstable_v2_createSession.mockReturnValue(mockSessionObj);
|
|
371
|
+
|
|
372
|
+
const adapter = createClaudeAdapter({
|
|
373
|
+
model: "claude-sonnet-4-6",
|
|
374
|
+
effort: "high",
|
|
375
|
+
isDefault: () => false,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await expect(adapter.createSession("/tmp")).rejects.toThrow(
|
|
379
|
+
"No session ID",
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("createSession sends 'ok' to the session", async () => {
|
|
384
|
+
const sendSpy = vi.fn();
|
|
385
|
+
const mockSessionObj = mockSession({
|
|
386
|
+
send: sendSpy,
|
|
387
|
+
streamNext: vi
|
|
388
|
+
.fn()
|
|
389
|
+
.mockResolvedValueOnce({
|
|
390
|
+
done: false,
|
|
391
|
+
value: { session_id: "sid", type: "system", subtype: "init" },
|
|
392
|
+
})
|
|
393
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
394
|
+
});
|
|
395
|
+
sdk.unstable_v2_createSession.mockReturnValue(mockSessionObj);
|
|
396
|
+
|
|
397
|
+
const adapter = createClaudeAdapter({
|
|
398
|
+
model: "claude-sonnet-4-6",
|
|
399
|
+
effort: "high",
|
|
400
|
+
isDefault: () => false,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await adapter.createSession("/tmp");
|
|
404
|
+
expect(sendSpy).toHaveBeenCalledWith("ok");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("prompt yields normalized messages", async () => {
|
|
408
|
+
const mockSessionObj = mockSession({
|
|
409
|
+
streamNext: (() => {
|
|
410
|
+
let callCount = 0;
|
|
411
|
+
return vi.fn(async () => {
|
|
412
|
+
callCount++;
|
|
413
|
+
if (callCount === 1) {
|
|
414
|
+
return {
|
|
415
|
+
done: false,
|
|
416
|
+
value: {
|
|
417
|
+
type: "assistant",
|
|
418
|
+
message: { content: [{ type: "text", text: "Hello!" }] },
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return { done: true, value: undefined };
|
|
423
|
+
});
|
|
424
|
+
})(),
|
|
425
|
+
});
|
|
426
|
+
sdk.unstable_v2_resumeSession.mockReturnValue(mockSessionObj);
|
|
427
|
+
|
|
428
|
+
const adapter = createClaudeAdapter({
|
|
429
|
+
model: "claude-sonnet-4-6",
|
|
430
|
+
effort: "high",
|
|
431
|
+
isDefault: () => false,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const messages: UnifiedStreamMessage[] = [];
|
|
435
|
+
for await (const msg of adapter.prompt("sid", "hi", "/tmp")) {
|
|
436
|
+
messages.push(msg);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
expect(messages).toHaveLength(1);
|
|
440
|
+
expect(messages[0].type).toBe("assistant");
|
|
441
|
+
expect(messages[0].blocks).toEqual([{ type: "text", text: "Hello!" }]);
|
|
442
|
+
expect(sdk.unstable_v2_resumeSession).toHaveBeenCalledTimes(1);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("prompt handles AbortSignal", async () => {
|
|
446
|
+
const mockSessionObj = mockSession({
|
|
447
|
+
streamNext: vi
|
|
448
|
+
.fn()
|
|
449
|
+
.mockResolvedValueOnce({
|
|
450
|
+
done: false,
|
|
451
|
+
value: {
|
|
452
|
+
type: "assistant",
|
|
453
|
+
message: { content: [{ type: "text", text: "msg1" }] },
|
|
454
|
+
},
|
|
455
|
+
})
|
|
456
|
+
.mockResolvedValueOnce({
|
|
457
|
+
done: false,
|
|
458
|
+
value: {
|
|
459
|
+
type: "assistant",
|
|
460
|
+
message: { content: [{ type: "text", text: "msg2" }] },
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
.mockResolvedValue({ done: true, value: undefined }),
|
|
464
|
+
});
|
|
465
|
+
sdk.unstable_v2_resumeSession.mockReturnValue(mockSessionObj);
|
|
466
|
+
|
|
467
|
+
const controller = new AbortController();
|
|
468
|
+
const adapter = createClaudeAdapter({
|
|
469
|
+
model: "claude-sonnet-4-6",
|
|
470
|
+
effort: "high",
|
|
471
|
+
isDefault: () => false,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const messages: UnifiedStreamMessage[] = [];
|
|
475
|
+
for await (const msg of adapter.prompt("sid", "hi", "/tmp", controller.signal)) {
|
|
476
|
+
messages.push(msg);
|
|
477
|
+
if (messages.length === 1) controller.abort();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Should only get the first message before abort stops iteration
|
|
481
|
+
expect(messages).toHaveLength(1);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("getSessionInfo returns mapped SessionInfo", async () => {
|
|
485
|
+
sdk.getSessionInfo.mockResolvedValue({
|
|
486
|
+
sessionId: "sid",
|
|
487
|
+
cwd: "/home/user",
|
|
488
|
+
summary: "A test session",
|
|
489
|
+
lastModified: 1234567890,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const adapter = createClaudeAdapter({
|
|
493
|
+
model: "claude-sonnet-4-6",
|
|
494
|
+
effort: "high",
|
|
495
|
+
isDefault: () => false,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const info = await adapter.getSessionInfo("sid");
|
|
499
|
+
expect(info).toEqual({
|
|
500
|
+
sessionId: "sid",
|
|
501
|
+
cwd: "/home/user",
|
|
502
|
+
summary: "A test session",
|
|
503
|
+
lastModified: 1234567890,
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("getSessionInfo returns undefined when SDK returns undefined", async () => {
|
|
508
|
+
sdk.getSessionInfo.mockResolvedValue(undefined);
|
|
509
|
+
|
|
510
|
+
const adapter = createClaudeAdapter({
|
|
511
|
+
model: "claude-sonnet-4-6",
|
|
512
|
+
effort: "high",
|
|
513
|
+
isDefault: () => false,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const info = await adapter.getSessionInfo("nonexistent");
|
|
517
|
+
expect(info).toBeUndefined();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("closeSession is no-op (does not throw)", async () => {
|
|
521
|
+
const adapter = createClaudeAdapter({
|
|
522
|
+
model: "claude-sonnet-4-6",
|
|
523
|
+
effort: "high",
|
|
524
|
+
isDefault: () => false,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await expect(adapter.closeSession("any-sid")).resolves.toBeUndefined();
|
|
528
|
+
});
|
|
529
|
+
});
|