chatccc 0.2.192 → 0.2.193
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/bin/cccagent.mjs +17 -0
- package/package.json +3 -2
- package/src/__tests__/builtin-cli-json.test.ts +39 -0
- package/src/__tests__/builtin-context.test.ts +37 -0
- package/src/__tests__/builtin-session-select.test.ts +116 -0
- package/src/__tests__/cards.test.ts +32 -11
- package/src/__tests__/ccc-adapter.test.ts +73 -0
- package/src/__tests__/orchestrator.test.ts +34 -9
- package/src/__tests__/session.test.ts +23 -13
- package/src/__tests__/sim-platform.test.ts +12 -7
- package/src/adapters/ccc-adapter.ts +99 -0
- package/src/builtin/cli.ts +299 -69
- package/src/builtin/context.ts +112 -14
- package/src/builtin/index.ts +1 -0
- package/src/builtin/session-select.ts +48 -0
- package/src/cards.ts +58 -35
- package/src/config.ts +24 -18
- package/src/feishu-api.ts +14 -12
- package/src/orchestrator.ts +1 -1
- package/src/session.ts +31 -15
package/bin/cccagent.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pkgRoot = dirname(require.resolve("../package.json"));
|
|
8
|
+
const cliTs = join(pkgRoot, "src", "builtin", "cli.ts");
|
|
9
|
+
const tsxCli = require.resolve("tsx/cli");
|
|
10
|
+
|
|
11
|
+
const result = spawnSync(process.execPath, [tsxCli, cliTs, ...process.argv.slice(2)], {
|
|
12
|
+
stdio: "inherit",
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
env: process.env,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.193",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"chatccc": "bin/chatccc.mjs"
|
|
9
|
+
"chatccc": "bin/chatccc.mjs",
|
|
10
|
+
"cccagent": "bin/cccagent.mjs"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"src/",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
describe("builtin cli --stream-json", () => {
|
|
9
|
+
it("writes only JSON lines to stdout when startup fails", async () => {
|
|
10
|
+
let caught: unknown;
|
|
11
|
+
try {
|
|
12
|
+
await execFileAsync(process.execPath, [
|
|
13
|
+
"bin/cccagent.mjs",
|
|
14
|
+
"--stream-json",
|
|
15
|
+
"--prompt",
|
|
16
|
+
"hello",
|
|
17
|
+
"--api-key",
|
|
18
|
+
"",
|
|
19
|
+
], {
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
timeout: 10_000,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
} catch (err) {
|
|
25
|
+
caught = err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
expect(caught).toMatchObject({ code: 1 });
|
|
29
|
+
const stdout = (caught as { stdout?: string }).stdout ?? "";
|
|
30
|
+
const lines = stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
31
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
34
|
+
}
|
|
35
|
+
expect(JSON.parse(lines.at(-1)!)).toEqual(expect.objectContaining({
|
|
36
|
+
type: "error",
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest";
|
|
|
7
7
|
import {
|
|
8
8
|
BuiltinContextManager,
|
|
9
9
|
estimateBuiltinContextTokens,
|
|
10
|
+
listBuiltinContextSessions,
|
|
11
|
+
newBuiltinSessionId,
|
|
10
12
|
serializeMessagesForSummary,
|
|
11
13
|
} from "../builtin/context.ts";
|
|
12
14
|
|
|
@@ -109,9 +111,44 @@ describe("BuiltinContextManager", () => {
|
|
|
109
111
|
expect(persisted.messages).toEqual([]);
|
|
110
112
|
expect(persisted.totalMessages).toBe(0);
|
|
111
113
|
});
|
|
114
|
+
|
|
115
|
+
it("persists cwd metadata and lists saved sessions newest first", async () => {
|
|
116
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-context-list-"));
|
|
117
|
+
|
|
118
|
+
const first = new BuiltinContextManager({
|
|
119
|
+
persist: true,
|
|
120
|
+
contextDir: dir,
|
|
121
|
+
sessionId: "older",
|
|
122
|
+
cwd: "C:\\repo-a",
|
|
123
|
+
});
|
|
124
|
+
first.appendMessage({ role: "user", content: "old" });
|
|
125
|
+
|
|
126
|
+
const second = new BuiltinContextManager({
|
|
127
|
+
persist: true,
|
|
128
|
+
contextDir: dir,
|
|
129
|
+
sessionId: "newer",
|
|
130
|
+
cwd: "C:\\repo-b",
|
|
131
|
+
});
|
|
132
|
+
second.appendMessage({ role: "user", content: "new" });
|
|
133
|
+
|
|
134
|
+
const sessions = listBuiltinContextSessions(dir);
|
|
135
|
+
|
|
136
|
+
expect(sessions.map((s) => s.sessionId)).toEqual(["newer", "older"]);
|
|
137
|
+
expect(sessions[0]).toEqual(expect.objectContaining({
|
|
138
|
+
cwd: "C:\\repo-b",
|
|
139
|
+
totalMessages: 1,
|
|
140
|
+
hasSummary: false,
|
|
141
|
+
}));
|
|
142
|
+
});
|
|
112
143
|
});
|
|
113
144
|
|
|
114
145
|
describe("builtin context helpers", () => {
|
|
146
|
+
it("creates readable timestamp-based session ids", () => {
|
|
147
|
+
const id = newBuiltinSessionId(new Date(2026, 6, 2, 12, 15, 30), "a1b2c3");
|
|
148
|
+
|
|
149
|
+
expect(id).toBe("session-20260702-121530-a1b2c3");
|
|
150
|
+
});
|
|
151
|
+
|
|
115
152
|
it("estimates tokens from summary and messages", () => {
|
|
116
153
|
expect(estimateBuiltinContextTokens("abc", [{ role: "user", content: "abcdef" }]))
|
|
117
154
|
.toBeGreaterThanOrEqual(3);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { defaultBuiltinSessionId } from "../builtin/context.ts";
|
|
8
|
+
import { resolveBuiltinSession } from "../builtin/session-select.ts";
|
|
9
|
+
|
|
10
|
+
async function writeSession(
|
|
11
|
+
contextDir: string,
|
|
12
|
+
sessionId: string,
|
|
13
|
+
options: { cwd?: string; updatedAt: number; totalMessages?: number },
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const dir = join(contextDir, sessionId);
|
|
16
|
+
await mkdir(dir, { recursive: true });
|
|
17
|
+
await writeFile(
|
|
18
|
+
join(dir, "context.json"),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
version: 1,
|
|
21
|
+
createdAt: options.updatedAt,
|
|
22
|
+
updatedAt: options.updatedAt,
|
|
23
|
+
sessionId,
|
|
24
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
25
|
+
summary: "",
|
|
26
|
+
messages: [],
|
|
27
|
+
totalMessages: options.totalMessages ?? 0,
|
|
28
|
+
compactedMessages: 0,
|
|
29
|
+
}),
|
|
30
|
+
"utf8",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("resolveBuiltinSession", () => {
|
|
35
|
+
it("creates a fresh timestamp session by default", async () => {
|
|
36
|
+
const contextDir = await mkdtemp(join(tmpdir(), "chatccc-session-select-new-"));
|
|
37
|
+
|
|
38
|
+
const result = resolveBuiltinSession({
|
|
39
|
+
cwd: "C:\\repo",
|
|
40
|
+
contextDir,
|
|
41
|
+
now: new Date(2026, 6, 2, 12, 15, 30),
|
|
42
|
+
randomSuffix: "a1b2c3",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual({
|
|
46
|
+
mode: "new",
|
|
47
|
+
sessionId: "session-20260702-121530-a1b2c3",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("resumes an explicit existing session id", async () => {
|
|
52
|
+
const contextDir = join(tmpdir(), `chatccc-session-select-explicit-${Date.now()}`);
|
|
53
|
+
await writeSession(contextDir, "manual-session", { updatedAt: 1 });
|
|
54
|
+
|
|
55
|
+
expect(resolveBuiltinSession({
|
|
56
|
+
cwd: "C:\\repo",
|
|
57
|
+
contextDir,
|
|
58
|
+
resume: "manual-session",
|
|
59
|
+
})).toEqual({
|
|
60
|
+
mode: "resumed",
|
|
61
|
+
sessionId: "manual-session",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fails when an explicit session id does not exist", async () => {
|
|
66
|
+
const contextDir = join(tmpdir(), `chatccc-session-select-missing-${Date.now()}`);
|
|
67
|
+
|
|
68
|
+
expect(() => resolveBuiltinSession({
|
|
69
|
+
cwd: "C:\\repo",
|
|
70
|
+
contextDir,
|
|
71
|
+
resume: "missing",
|
|
72
|
+
})).toThrow("未找到 ccc 会话: missing");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("resumes the newest session for cwd when resume has no id", async () => {
|
|
76
|
+
const contextDir = join(tmpdir(), `chatccc-session-select-cwd-${Date.now()}`);
|
|
77
|
+
await writeSession(contextDir, "old-match", { cwd: "C:\\repo", updatedAt: 1_000 });
|
|
78
|
+
await writeSession(contextDir, "new-match", { cwd: "C:\\repo", updatedAt: 2_000 });
|
|
79
|
+
await writeSession(contextDir, "other-cwd", { cwd: "C:\\other", updatedAt: 3_000 });
|
|
80
|
+
|
|
81
|
+
expect(resolveBuiltinSession({
|
|
82
|
+
cwd: "C:\\repo",
|
|
83
|
+
contextDir,
|
|
84
|
+
resume: true,
|
|
85
|
+
})).toEqual({
|
|
86
|
+
mode: "resumed",
|
|
87
|
+
sessionId: "new-match",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resumes legacy cwd-hash sessions when resume has no id", async () => {
|
|
92
|
+
const contextDir = join(tmpdir(), `chatccc-session-select-legacy-${Date.now()}`);
|
|
93
|
+
const cwd = "C:\\repo";
|
|
94
|
+
const legacySessionId = defaultBuiltinSessionId(cwd);
|
|
95
|
+
await writeSession(contextDir, legacySessionId, { updatedAt: 1_000 });
|
|
96
|
+
|
|
97
|
+
expect(resolveBuiltinSession({
|
|
98
|
+
cwd,
|
|
99
|
+
contextDir,
|
|
100
|
+
resume: true,
|
|
101
|
+
})).toEqual({
|
|
102
|
+
mode: "resumed",
|
|
103
|
+
sessionId: legacySessionId,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("fails when there is no cwd session to resume", async () => {
|
|
108
|
+
const contextDir = join(tmpdir(), `chatccc-session-select-empty-${Date.now()}`);
|
|
109
|
+
|
|
110
|
+
expect(() => resolveBuiltinSession({
|
|
111
|
+
cwd: "C:\\repo",
|
|
112
|
+
contextDir,
|
|
113
|
+
resume: true,
|
|
114
|
+
})).toThrow("未找到当前目录可恢复的 ccc 会话");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -170,6 +170,15 @@ describe("buildHelpCard", () => {
|
|
|
170
170
|
expect(lines).toContain("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡");
|
|
171
171
|
expect(lines.at(-1)).toBe(ABD_HELP_LINE);
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
it("does not advertise the hidden ccc agent", () => {
|
|
175
|
+
const card = buildHelpCard("test");
|
|
176
|
+
const parsed = JSON.parse(card);
|
|
177
|
+
const text = JSON.stringify(parsed);
|
|
178
|
+
|
|
179
|
+
expect(text).not.toContain("/new ccc");
|
|
180
|
+
expect(text).not.toContain("CCC Agent");
|
|
181
|
+
});
|
|
173
182
|
});
|
|
174
183
|
|
|
175
184
|
// ---------------------------------------------------------------------------
|
|
@@ -364,17 +373,29 @@ describe("buildSessionsCard", () => {
|
|
|
364
373
|
expect(content).toContain("Cursor 会话");
|
|
365
374
|
});
|
|
366
375
|
|
|
367
|
-
it("omits Cursor section when no Cursor sessions", () => {
|
|
368
|
-
const card = buildSessionsCard([
|
|
369
|
-
{ sessionId: "c1", chatName: "", chatId: "oc_c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
370
|
-
]);
|
|
371
|
-
const parsed = JSON.parse(card);
|
|
372
|
-
const content: string = parsed.elements[0].text.content;
|
|
373
|
-
expect(content).toContain("Claude Code 会话");
|
|
374
|
-
expect(content).not.toContain("Cursor 会话");
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
it("
|
|
376
|
+
it("omits Cursor section when no Cursor sessions", () => {
|
|
377
|
+
const card = buildSessionsCard([
|
|
378
|
+
{ sessionId: "c1", chatName: "", chatId: "oc_c1", active: false, turnCount: 1, elapsedSeconds: null, model: "(留空)", tool: "claude" },
|
|
379
|
+
]);
|
|
380
|
+
const parsed = JSON.parse(card);
|
|
381
|
+
const content: string = parsed.elements[0].text.content;
|
|
382
|
+
expect(content).toContain("Claude Code 会话");
|
|
383
|
+
expect(content).not.toContain("Cursor 会话");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("labels hidden ccc sessions without grouping them as Claude Code", () => {
|
|
387
|
+
const card = buildSessionsCard([
|
|
388
|
+
{ sessionId: "session-20260702-121530-a1b2c3", chatName: "", chatId: "oc_ccc", active: false, turnCount: 1, elapsedSeconds: null, model: "deepseek-v4-pro", tool: "ccc" },
|
|
389
|
+
]);
|
|
390
|
+
const parsed = JSON.parse(card);
|
|
391
|
+
const content: string = parsed.elements[0].text.content;
|
|
392
|
+
|
|
393
|
+
expect(content).toContain("CCC Agent 会话");
|
|
394
|
+
expect(content).toContain("工具: CCC Agent");
|
|
395
|
+
expect(content).not.toContain("Claude Code 会话");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("displays chatName when provided", () => {
|
|
378
399
|
const card = buildSessionsCard([
|
|
379
400
|
{ sessionId: "abc123", chatName: "帮我写代码-src", chatId: "oc_abc123", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
|
|
380
401
|
]);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
const streamTextMock = vi.fn();
|
|
8
|
+
const generateTextMock = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("@ai-sdk/openai-compatible", () => ({
|
|
11
|
+
createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("ai", () => ({
|
|
15
|
+
streamText: streamTextMock,
|
|
16
|
+
generateText: generateTextMock,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
async function* textStream(...chunks: string[]): AsyncIterable<string> {
|
|
20
|
+
for (const chunk of chunks) yield chunk;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
streamTextMock.mockReset();
|
|
25
|
+
generateTextMock.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("createCccAdapter", () => {
|
|
29
|
+
it("creates a persisted ccc session and exposes model/cwd metadata", async () => {
|
|
30
|
+
const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
|
|
31
|
+
const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-meta-"));
|
|
32
|
+
const adapter = createCccAdapter({
|
|
33
|
+
apiKey: "sk-test",
|
|
34
|
+
contextDir,
|
|
35
|
+
model: "deepseek-v4-pro",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const created = await adapter.createSession("F:\\repo");
|
|
39
|
+
const info = await adapter.getSessionInfo(created.sessionId);
|
|
40
|
+
|
|
41
|
+
expect(created.sessionId).toMatch(/^session-\d{8}-\d{6}-[a-f0-9]{6}$/);
|
|
42
|
+
expect(info).toEqual(expect.objectContaining({
|
|
43
|
+
sessionId: created.sessionId,
|
|
44
|
+
cwd: "F:\\repo",
|
|
45
|
+
model: "deepseek-v4-pro",
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("maps ChatSession text chunks to unified assistant text blocks", async () => {
|
|
50
|
+
const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
|
|
51
|
+
const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-stream-"));
|
|
52
|
+
const adapter = createCccAdapter({
|
|
53
|
+
apiKey: "sk-test",
|
|
54
|
+
contextDir,
|
|
55
|
+
model: "deepseek-v4-flash",
|
|
56
|
+
});
|
|
57
|
+
const { sessionId } = await adapter.createSession("F:\\repo");
|
|
58
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("hello", " world") });
|
|
59
|
+
|
|
60
|
+
const messages = [];
|
|
61
|
+
for await (const message of adapter.prompt(sessionId, "hi", "F:\\repo")) {
|
|
62
|
+
messages.push(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
expect(messages).toEqual([
|
|
66
|
+
{ type: "assistant", blocks: [{ type: "text", text: "hello" }] },
|
|
67
|
+
{ type: "assistant", blocks: [{ type: "text", text: " world" }] },
|
|
68
|
+
]);
|
|
69
|
+
expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
70
|
+
model: { modelId: "deepseek-v4-flash" },
|
|
71
|
+
}));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -626,9 +626,9 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
626
626
|
);
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
-
it("advertises /usage in new Codex and Cursor session ready messages", async () => {
|
|
630
|
-
const codexPlatform = mockPlatform("feishu");
|
|
631
|
-
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
629
|
+
it("advertises /usage in new Codex and Cursor session ready messages", async () => {
|
|
630
|
+
const codexPlatform = mockPlatform("feishu");
|
|
631
|
+
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
632
632
|
|
|
633
633
|
await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
634
634
|
|
|
@@ -652,9 +652,34 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
652
652
|
const claudePlatform = mockPlatform("feishu");
|
|
653
653
|
await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
|
|
654
654
|
|
|
655
|
-
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
656
|
-
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
657
|
-
);
|
|
658
|
-
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
659
|
-
});
|
|
660
|
-
|
|
655
|
+
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
656
|
+
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
657
|
+
);
|
|
658
|
+
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("allows the hidden /new ccc entry without advertising it in normal help", async () => {
|
|
662
|
+
const platform = mockPlatform("feishu");
|
|
663
|
+
_setAdapterForToolForTest("ccc", mockAdapter("session-20260702-121530-a1b2c3"));
|
|
664
|
+
|
|
665
|
+
await handleCommand(platform, "/new ccc", "feishu-p2p-ccc", "ou-user", Date.now(), "p2p");
|
|
666
|
+
|
|
667
|
+
expect(platform.updateChatInfo).toHaveBeenCalledWith(
|
|
668
|
+
"feishu-group",
|
|
669
|
+
expect.any(String),
|
|
670
|
+
"CCC Session: session-20260702-121530-a1b2c3",
|
|
671
|
+
);
|
|
672
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
673
|
+
"feishu-group",
|
|
674
|
+
"CCC Agent Session Ready",
|
|
675
|
+
expect.stringContaining("CCC Agent"),
|
|
676
|
+
"green",
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
const helpPlatform = mockPlatform("feishu");
|
|
680
|
+
await handleCommand(helpPlatform, "hello", "feishu-group-help", "ou-user", Date.now(), "group");
|
|
681
|
+
const helpCard = vi.mocked(helpPlatform.sendRawCard).mock.calls.at(-1)?.[1] as string;
|
|
682
|
+
expect(helpCard).not.toContain("/new ccc");
|
|
683
|
+
expect(helpCard).not.toContain("CCC Agent");
|
|
684
|
+
});
|
|
685
|
+
});
|
|
@@ -942,19 +942,29 @@ describe("getSessionStatus", () => {
|
|
|
942
942
|
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
943
943
|
});
|
|
944
944
|
|
|
945
|
-
it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
|
|
946
|
-
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
947
|
-
_setAdapterForToolForTest(
|
|
948
|
-
"cursor",
|
|
949
|
-
mockAdapter(() => {
|
|
950
|
-
throw new Error("simulated adapter failure");
|
|
951
|
-
}),
|
|
952
|
-
);
|
|
953
|
-
const status = await getSessionStatus("chat-cursor");
|
|
954
|
-
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
955
|
-
expect(status!.effort).toBeNull();
|
|
956
|
-
});
|
|
957
|
-
|
|
945
|
+
it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
|
|
946
|
+
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
947
|
+
_setAdapterForToolForTest(
|
|
948
|
+
"cursor",
|
|
949
|
+
mockAdapter(() => {
|
|
950
|
+
throw new Error("simulated adapter failure");
|
|
951
|
+
}),
|
|
952
|
+
);
|
|
953
|
+
const status = await getSessionStatus("chat-cursor");
|
|
954
|
+
expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
955
|
+
expect(status!.effort).toBeNull();
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
|
|
959
|
+
mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
|
|
960
|
+
|
|
961
|
+
const status = await getSessionStatus("chat-ccc");
|
|
962
|
+
|
|
963
|
+
expect(status!.model).toBeTruthy();
|
|
964
|
+
expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
|
|
965
|
+
expect(status!.effort).toBeNull();
|
|
966
|
+
});
|
|
967
|
+
});
|
|
958
968
|
|
|
959
969
|
describe("getAllSessionsStatus", () => {
|
|
960
970
|
let registryFile = "";
|
|
@@ -62,16 +62,21 @@ describe("SimulatedPlatform", () => {
|
|
|
62
62
|
await expect(SimulatedPlatform.setChatAvatar("t", "ch1", "claude", "busy")).resolves.toBeUndefined();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it("纯函数 extractSessionInfo 正常工作", () => {
|
|
66
|
-
const result = SimulatedPlatform.extractSessionInfo("Claude Code Session: a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
|
67
|
-
expect(result).toEqual({ sessionId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", tool: "claude" });
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("
|
|
65
|
+
it("纯函数 extractSessionInfo 正常工作", () => {
|
|
66
|
+
const result = SimulatedPlatform.extractSessionInfo("Claude Code Session: a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
|
67
|
+
expect(result).toEqual({ sessionId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", tool: "claude" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("pure extractSessionInfo recognizes ccc session ids", () => {
|
|
71
|
+
const result = SimulatedPlatform.extractSessionInfo("CCC Session: session-20260702-121530-a1b2c3");
|
|
72
|
+
expect(result).toEqual({ sessionId: "session-20260702-121530-a1b2c3", tool: "ccc" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("纯函数 formatDelayNotice 正常工作", () => {
|
|
71
76
|
const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
|
|
72
77
|
expect(notice).toBeDefined();
|
|
73
78
|
expect(notice).toContain("延迟送达");
|
|
74
79
|
// 近期消息不触发
|
|
75
80
|
expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
|
|
76
81
|
});
|
|
77
|
-
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ChatSession, type ChatSessionConfig, type ChatSessionOptions } from "../builtin/index.ts";
|
|
2
|
+
import {
|
|
3
|
+
getBuiltinContextSession,
|
|
4
|
+
newBuiltinSessionId,
|
|
5
|
+
normalizeBuiltinSessionId,
|
|
6
|
+
} from "../builtin/context.ts";
|
|
7
|
+
import { config, CCC_SESSION_PREFIX } from "../config.ts";
|
|
8
|
+
import type {
|
|
9
|
+
CreateSessionResult,
|
|
10
|
+
SessionInfo,
|
|
11
|
+
ToolAdapter,
|
|
12
|
+
ToolPromptOptions,
|
|
13
|
+
UnifiedStreamMessage,
|
|
14
|
+
} from "./adapter-interface.ts";
|
|
15
|
+
|
|
16
|
+
export interface CccAdapterOptions extends ChatSessionConfig {
|
|
17
|
+
contextDir?: string;
|
|
18
|
+
compactAtTokens?: number;
|
|
19
|
+
keepRecentMessages?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toChatSessionOptions(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
cwd: string,
|
|
25
|
+
options: CccAdapterOptions,
|
|
26
|
+
): ChatSessionOptions {
|
|
27
|
+
return {
|
|
28
|
+
cwd,
|
|
29
|
+
persist: true,
|
|
30
|
+
sessionId,
|
|
31
|
+
contextDir: options.contextDir,
|
|
32
|
+
compactAtTokens: options.compactAtTokens,
|
|
33
|
+
keepRecentMessages: options.keepRecentMessages,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
|
|
38
|
+
const chatConfig: ChatSessionConfig = {
|
|
39
|
+
...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
|
|
40
|
+
...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
|
|
41
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
displayName: "CCC Agent",
|
|
46
|
+
sessionDescPrefix: CCC_SESSION_PREFIX,
|
|
47
|
+
|
|
48
|
+
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
49
|
+
const sessionId = newBuiltinSessionId();
|
|
50
|
+
const session = new ChatSession(
|
|
51
|
+
chatConfig,
|
|
52
|
+
toChatSessionOptions(sessionId, cwd, options),
|
|
53
|
+
);
|
|
54
|
+
session.reset();
|
|
55
|
+
return { sessionId };
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async *prompt(
|
|
59
|
+
sessionId: string,
|
|
60
|
+
userText: string,
|
|
61
|
+
cwd: string,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
_promptOptions?: ToolPromptOptions,
|
|
64
|
+
): AsyncIterable<UnifiedStreamMessage> {
|
|
65
|
+
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
66
|
+
const session = new ChatSession(
|
|
67
|
+
chatConfig,
|
|
68
|
+
toChatSessionOptions(normalizedSessionId, cwd, options),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for await (const event of session.chat(userText, signal)) {
|
|
72
|
+
if (event.type === "text") {
|
|
73
|
+
yield {
|
|
74
|
+
type: "assistant",
|
|
75
|
+
blocks: [{ type: "text", text: event.text }],
|
|
76
|
+
};
|
|
77
|
+
} else if (event.type === "error") {
|
|
78
|
+
throw new Error(event.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async getSessionInfo(sessionId: string): Promise<SessionInfo | undefined> {
|
|
84
|
+
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
85
|
+
const info = getBuiltinContextSession(normalizedSessionId, options.contextDir);
|
|
86
|
+
if (!info) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
sessionId: info.sessionId,
|
|
89
|
+
cwd: info.cwd,
|
|
90
|
+
lastModified: info.updatedAt,
|
|
91
|
+
model: options.model ?? config.ccc.model,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async closeSession(_sessionId: string): Promise<void> {
|
|
96
|
+
// ChatSession uses one request-scoped stream per prompt. AbortSignal handles cancellation.
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|