chatccc 0.2.191 → 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.
@@ -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.191",
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,76 @@
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 collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
20
+ const events: unknown[] = [];
21
+ for await (const event of iterable) events.push(event);
22
+ return events;
23
+ }
24
+
25
+ async function* textStream(...chunks: string[]): AsyncIterable<string> {
26
+ for (const chunk of chunks) yield chunk;
27
+ }
28
+
29
+ afterEach(() => {
30
+ streamTextMock.mockReset();
31
+ generateTextMock.mockReset();
32
+ });
33
+
34
+ describe("ChatSession context management", () => {
35
+ it("loads persisted context, compacts older messages, and persists the new assistant reply", async () => {
36
+ const { ChatSession } = await import("../builtin/index.ts");
37
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-session-context-"));
38
+
39
+ const seed = new ChatSession(
40
+ { apiKey: "sk-test" },
41
+ {
42
+ persist: true,
43
+ contextDir: dir,
44
+ sessionId: "integration",
45
+ compactAtTokens: 10_000,
46
+ },
47
+ );
48
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
49
+ await collect(seed.chat("old question"));
50
+
51
+ generateTextMock.mockResolvedValueOnce({ text: "## 当前任务状态\n- 旧问题已总结" });
52
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
53
+
54
+ const restored = new ChatSession(
55
+ { apiKey: "sk-test" },
56
+ {
57
+ persist: true,
58
+ contextDir: dir,
59
+ sessionId: "integration",
60
+ compactAtTokens: 1,
61
+ keepRecentMessages: 1,
62
+ },
63
+ );
64
+ const events = await collect(restored.chat("new question"));
65
+
66
+ expect(generateTextMock).toHaveBeenCalledOnce();
67
+ expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
68
+ messages: expect.arrayContaining([
69
+ expect.objectContaining({ content: expect.stringContaining("旧问题已总结") }),
70
+ expect.objectContaining({ role: "user", content: "new question" }),
71
+ ]),
72
+ }));
73
+ expect(events).toContainEqual({ type: "compact", compactedMessages: 2 });
74
+ expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
75
+ });
76
+ });
@@ -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
+ });
@@ -0,0 +1,163 @@
1
+ import { mkdtemp, readFile } 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 {
8
+ BuiltinContextManager,
9
+ estimateBuiltinContextTokens,
10
+ listBuiltinContextSessions,
11
+ newBuiltinSessionId,
12
+ serializeMessagesForSummary,
13
+ } from "../builtin/context.ts";
14
+
15
+ describe("BuiltinContextManager", () => {
16
+ it("persists and restores summary, messages, and total message count", async () => {
17
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-context-"));
18
+
19
+ const first = new BuiltinContextManager({
20
+ persist: true,
21
+ contextDir: dir,
22
+ sessionId: "persisted",
23
+ });
24
+ first.setSummary("## 用户目标\n- 保留这个摘要");
25
+ first.appendMessage({ role: "user", content: "第一轮" });
26
+ first.appendMessage({ role: "assistant", content: "第一轮回复" });
27
+ first.save();
28
+
29
+ const restored = new BuiltinContextManager({
30
+ persist: true,
31
+ contextDir: dir,
32
+ sessionId: "persisted",
33
+ });
34
+
35
+ expect(restored.summary).toContain("保留这个摘要");
36
+ expect(restored.messages).toEqual([
37
+ { role: "user", content: "第一轮" },
38
+ { role: "assistant", content: "第一轮回复" },
39
+ ]);
40
+ expect(restored.totalMessages).toBe(2);
41
+ });
42
+
43
+ it("selects only older messages for compaction and keeps recent messages raw", () => {
44
+ const context = new BuiltinContextManager({
45
+ compactAtTokens: 1,
46
+ keepRecentMessages: 2,
47
+ persist: false,
48
+ });
49
+ context.appendMessage({ role: "user", content: "旧用户消息" });
50
+ context.appendMessage({ role: "assistant", content: "旧助手回复" });
51
+ context.appendMessage({ role: "user", content: "近期用户消息" });
52
+ context.appendMessage({ role: "assistant", content: "近期助手回复" });
53
+
54
+ const plan = context.planCompaction();
55
+
56
+ expect(plan).not.toBeNull();
57
+ expect(plan?.oldMessages).toEqual([
58
+ { role: "user", content: "旧用户消息" },
59
+ { role: "assistant", content: "旧助手回复" },
60
+ ]);
61
+ expect(plan?.recentMessages).toEqual([
62
+ { role: "user", content: "近期用户消息" },
63
+ { role: "assistant", content: "近期助手回复" },
64
+ ]);
65
+ });
66
+
67
+ it("applies a compacted summary and builds model messages with summary plus recent raw turns", () => {
68
+ const context = new BuiltinContextManager({
69
+ compactAtTokens: 1,
70
+ keepRecentMessages: 1,
71
+ persist: false,
72
+ });
73
+ context.appendMessage({ role: "user", content: "old" });
74
+ context.appendMessage({ role: "assistant", content: "recent" });
75
+
76
+ const plan = context.planCompaction();
77
+ expect(plan).not.toBeNull();
78
+ context.applyCompaction("## 当前任务状态\n- 已压缩旧上下文", plan!);
79
+
80
+ expect(context.summary).toContain("已压缩旧上下文");
81
+ expect(context.messages).toEqual([{ role: "assistant", content: "recent" }]);
82
+ expect(context.buildModelMessages()).toEqual([
83
+ {
84
+ role: "user",
85
+ content: expect.stringContaining("较早对话摘要"),
86
+ },
87
+ { role: "assistant", content: "recent" },
88
+ ]);
89
+ });
90
+
91
+ it("reset clears memory and the persisted context file", async () => {
92
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-context-reset-"));
93
+ const context = new BuiltinContextManager({
94
+ persist: true,
95
+ contextDir: dir,
96
+ sessionId: "reset",
97
+ });
98
+ context.setSummary("summary");
99
+ context.appendMessage({ role: "user", content: "hello" });
100
+ context.save();
101
+
102
+ context.reset();
103
+
104
+ expect(context.summary).toBe("");
105
+ expect(context.messages).toEqual([]);
106
+ expect(context.totalMessages).toBe(0);
107
+
108
+ const raw = await readFile(join(dir, "reset", "context.json"), "utf8");
109
+ const persisted = JSON.parse(raw) as { summary: string; messages: unknown[]; totalMessages: number };
110
+ expect(persisted.summary).toBe("");
111
+ expect(persisted.messages).toEqual([]);
112
+ expect(persisted.totalMessages).toBe(0);
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
+ });
143
+ });
144
+
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
+
152
+ it("estimates tokens from summary and messages", () => {
153
+ expect(estimateBuiltinContextTokens("abc", [{ role: "user", content: "abcdef" }]))
154
+ .toBeGreaterThanOrEqual(3);
155
+ });
156
+
157
+ it("serializes messages for summarization with stable role labels", () => {
158
+ expect(serializeMessagesForSummary([
159
+ { role: "user", content: "你好" },
160
+ { role: "assistant", content: "收到" },
161
+ ])).toContain("user");
162
+ });
163
+ });
@@ -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
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createCtrlCState } from "../builtin/sigint.ts";
4
+
5
+ describe("builtin CLI Ctrl+C state", () => {
6
+ it("requires two presses to interrupt an active generation", () => {
7
+ let now = 1_000;
8
+ const state = createCtrlCState({ windowMs: 2_000, now: () => now });
9
+
10
+ expect(state.press(true)).toBe("arm-interrupt");
11
+ expect(state.press(true)).toBe("interrupt");
12
+ });
13
+
14
+ it("requires two presses to exit while idle", () => {
15
+ let now = 1_000;
16
+ const state = createCtrlCState({ windowMs: 2_000, now: () => now });
17
+
18
+ expect(state.press(false)).toBe("arm-exit");
19
+ expect(state.press(false)).toBe("exit");
20
+ });
21
+
22
+ it("expires the pending confirmation after the window", () => {
23
+ let now = 1_000;
24
+ const state = createCtrlCState({ windowMs: 2_000, now: () => now });
25
+
26
+ expect(state.press(true)).toBe("arm-interrupt");
27
+
28
+ now += 2_001;
29
+
30
+ expect(state.press(true)).toBe("arm-interrupt");
31
+ expect(state.press(true)).toBe("interrupt");
32
+ });
33
+
34
+ it("does not convert a pending interrupt into an idle exit after reset", () => {
35
+ let now = 1_000;
36
+ const state = createCtrlCState({ windowMs: 2_000, now: () => now });
37
+
38
+ expect(state.press(true)).toBe("arm-interrupt");
39
+
40
+ state.reset();
41
+ now += 1_000;
42
+
43
+ expect(state.press(false)).toBe("arm-exit");
44
+ });
45
+
46
+ it("does not confirm a different action with the second press", () => {
47
+ let now = 1_000;
48
+ const state = createCtrlCState({ windowMs: 2_000, now: () => now });
49
+
50
+ expect(state.press(true)).toBe("arm-interrupt");
51
+ now += 500;
52
+
53
+ expect(state.press(false)).toBe("arm-exit");
54
+ expect(state.press(false)).toBe("exit");
55
+ });
56
+ });
@@ -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("displays chatName when provided", () => {
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
+ });