chatccc 0.2.51 → 0.2.53
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 +103 -217
- package/bin/chatccc.mjs +23 -23
- package/config.sample.json +34 -30
- package/demo/ilink_echo_probe.ts +222 -222
- package/im-skills/feishu-skill/download-video.mjs +162 -162
- package/im-skills/feishu-skill/receive-send-file.md +66 -66
- package/im-skills/feishu-skill/receive-send-image.md +27 -27
- package/im-skills/feishu-skill/send-file.mjs +50 -50
- package/im-skills/feishu-skill/send-image.mjs +50 -50
- package/im-skills/feishu-skill/skill.md +10 -10
- package/package.json +59 -59
- package/src/__tests__/agent-image-rpc.test.ts +33 -33
- package/src/__tests__/agent-rpc-body.test.ts +41 -41
- package/src/__tests__/card-plain-text.test.ts +42 -0
- package/src/__tests__/claude-adapter.test.ts +54 -1
- package/src/__tests__/codex-adapter.test.ts +304 -304
- package/src/__tests__/config-reload.test.ts +58 -41
- package/src/__tests__/config-sample.test.ts +19 -0
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
- package/src/__tests__/im-skills.test.ts +46 -46
- package/src/__tests__/session.test.ts +95 -2
- package/src/__tests__/sim-agent.test.ts +173 -173
- package/src/__tests__/sim-platform.test.ts +76 -76
- package/src/__tests__/sim-store.test.ts +213 -213
- package/src/__tests__/stream-state.test.ts +134 -134
- package/src/__tests__/wechat-platform.test.ts +57 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/adapters/codex-adapter.ts +294 -294
- package/src/adapters/codex-session-meta-store.ts +130 -130
- package/src/agent-file-rpc.ts +156 -156
- package/src/agent-image-rpc.ts +152 -152
- package/src/agent-rpc-body.ts +48 -48
- package/src/card-plain-text.ts +108 -0
- package/src/cards.ts +4 -4
- package/src/config.ts +775 -742
- package/src/feishu-api.ts +6 -0
- package/src/im-skills.ts +109 -109
- package/src/index.ts +155 -788
- package/src/orchestrator.ts +1032 -0
- package/src/platform-adapter.ts +61 -0
- package/src/session-chat-binding.ts +7 -0
- package/src/session.ts +278 -113
- package/src/sim-agent.ts +167 -156
- package/src/trace.ts +50 -50
- package/src/web-ui.ts +1891 -1718
- package/src/wechat-platform.ts +517 -0
|
@@ -14,13 +14,15 @@ import {
|
|
|
14
14
|
CLAUDE_MODEL,
|
|
15
15
|
CURSOR_AGENT_ARGS,
|
|
16
16
|
CURSOR_AGENT_COMMAND,
|
|
17
|
+
FEISHU_ENABLED,
|
|
17
18
|
GIT_TIMEOUT_MS,
|
|
18
19
|
GIT_TIMEOUT_SECONDS,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
ILINK_ENABLED,
|
|
21
|
+
applyLoadedConfig,
|
|
22
|
+
config,
|
|
23
|
+
resolveDefaultAgentTool,
|
|
24
|
+
type AppConfig,
|
|
25
|
+
} from "../config.ts";
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
|
|
@@ -35,18 +37,20 @@ import {
|
|
|
35
37
|
|
|
36
38
|
const baseAppConfig: AppConfig = {
|
|
37
39
|
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
40
|
+
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
38
41
|
port: 18080,
|
|
39
42
|
gitTimeoutSeconds: 180,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
allowInterrupt: false,
|
|
44
|
+
claude: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
defaultAgent: true,
|
|
47
|
+
model: "initial-model",
|
|
44
48
|
effort: "initial-effort",
|
|
45
49
|
apiKey: "sk-initial",
|
|
46
50
|
baseUrl: "https://initial.gw/anthropic",
|
|
47
51
|
},
|
|
48
|
-
cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
|
|
49
|
-
codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
|
|
52
|
+
cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
|
|
53
|
+
codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
// 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
|
|
@@ -77,10 +81,10 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
77
81
|
it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
|
|
78
82
|
applyLoadedConfig({
|
|
79
83
|
...structuredClone(baseAppConfig),
|
|
80
|
-
claude: {
|
|
81
|
-
enabled: true,
|
|
82
|
-
defaultAgent: true,
|
|
83
|
-
model: "deepseek-v4-pro",
|
|
84
|
+
claude: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
defaultAgent: true,
|
|
87
|
+
model: "deepseek-v4-pro",
|
|
84
88
|
effort: "high",
|
|
85
89
|
apiKey: "sk-newkey",
|
|
86
90
|
baseUrl: "https://gw2.example/anthropic",
|
|
@@ -104,10 +108,23 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
104
108
|
expect(GIT_TIMEOUT_MS).toBe(240 * 1000);
|
|
105
109
|
});
|
|
106
110
|
|
|
111
|
+
it("更新平台开关(默认飞书和微信都开启)", () => {
|
|
112
|
+
expect(FEISHU_ENABLED).toBe(true);
|
|
113
|
+
expect(ILINK_ENABLED).toBe(true);
|
|
114
|
+
|
|
115
|
+
applyLoadedConfig({
|
|
116
|
+
...structuredClone(baseAppConfig),
|
|
117
|
+
platforms: { feishu: { enabled: false }, ilink: { enabled: true } },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(FEISHU_ENABLED).toBe(false);
|
|
121
|
+
expect(ILINK_ENABLED).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
107
124
|
it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
|
|
108
125
|
applyLoadedConfig({
|
|
109
126
|
...structuredClone(baseAppConfig),
|
|
110
|
-
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
|
|
127
|
+
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
|
|
111
128
|
});
|
|
112
129
|
|
|
113
130
|
// CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
|
|
@@ -118,7 +135,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
118
135
|
it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
|
|
119
136
|
applyLoadedConfig({
|
|
120
137
|
...structuredClone(baseAppConfig),
|
|
121
|
-
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
|
|
138
|
+
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
|
|
122
139
|
});
|
|
123
140
|
|
|
124
141
|
expect(CURSOR_AGENT_ARGS).not.toContain("--model");
|
|
@@ -127,7 +144,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
127
144
|
it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
|
|
128
145
|
applyLoadedConfig({
|
|
129
146
|
...structuredClone(baseAppConfig),
|
|
130
|
-
cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
|
|
147
|
+
cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
|
|
131
148
|
});
|
|
132
149
|
|
|
133
150
|
expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
|
|
@@ -145,14 +162,14 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
145
162
|
});
|
|
146
163
|
});
|
|
147
164
|
|
|
148
|
-
describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
165
|
+
describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
149
166
|
it("config 引用保持不变(就地更新),但字段被刷新", () => {
|
|
150
167
|
const refBefore = config;
|
|
151
168
|
|
|
152
169
|
applyLoadedConfig({
|
|
153
170
|
...structuredClone(baseAppConfig),
|
|
154
171
|
feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
|
|
155
|
-
codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
|
|
172
|
+
codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
|
|
156
173
|
});
|
|
157
174
|
|
|
158
175
|
// 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
|
|
@@ -170,24 +187,24 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
|
170
187
|
expect(APP_ID).toBe("");
|
|
171
188
|
expect(APP_SECRET).toBe("");
|
|
172
189
|
expect(config.feishu.appId).toBe("");
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
describe("resolveDefaultAgentTool", () => {
|
|
177
|
-
it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
|
|
178
|
-
const cfg = structuredClone(baseAppConfig);
|
|
179
|
-
cfg.claude.defaultAgent = false;
|
|
180
|
-
cfg.cursor.defaultAgent = true;
|
|
181
|
-
|
|
182
|
-
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
186
|
-
const cfg = structuredClone(baseAppConfig);
|
|
187
|
-
cfg.claude.enabled = false;
|
|
188
|
-
cfg.claude.defaultAgent = true;
|
|
189
|
-
cfg.cursor.defaultAgent = false;
|
|
190
|
-
|
|
191
|
-
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
192
|
-
});
|
|
193
|
-
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("resolveDefaultAgentTool", () => {
|
|
194
|
+
it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
|
|
195
|
+
const cfg = structuredClone(baseAppConfig);
|
|
196
|
+
cfg.claude.defaultAgent = false;
|
|
197
|
+
cfg.cursor.defaultAgent = true;
|
|
198
|
+
|
|
199
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
203
|
+
const cfg = structuredClone(baseAppConfig);
|
|
204
|
+
cfg.claude.enabled = false;
|
|
205
|
+
cfg.claude.defaultAgent = true;
|
|
206
|
+
cfg.cursor.defaultAgent = false;
|
|
207
|
+
|
|
208
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
describe("config.sample.json", () => {
|
|
7
|
+
it("enables Feishu and WeChat iLink by default", () => {
|
|
8
|
+
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
9
|
+
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
10
|
+
platforms?: {
|
|
11
|
+
feishu?: { enabled?: unknown };
|
|
12
|
+
ilink?: { enabled?: unknown };
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
expect(sample.platforms?.feishu?.enabled).toBe(true);
|
|
17
|
+
expect(sample.platforms?.ilink?.enabled).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { describe, it, expect, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { buildCrashLoggingHandlers, installCrashLogging, setupFileLogging } from "../shared.ts";
|
|
7
7
|
|
|
8
8
|
describe("buildCrashLoggingHandlers", () => {
|
|
9
9
|
it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
|
|
@@ -144,7 +144,7 @@ describe("buildCrashLoggingHandlers", () => {
|
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
describe("installCrashLogging", () => {
|
|
147
|
+
describe("installCrashLogging", () => {
|
|
148
148
|
it("注册所有相关事件监听器", () => {
|
|
149
149
|
const before = {
|
|
150
150
|
uncaught: process.listenerCount("uncaughtException"),
|
|
@@ -209,58 +209,58 @@ describe("installCrashLogging", () => {
|
|
|
209
209
|
} finally {
|
|
210
210
|
cleanup();
|
|
211
211
|
}
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe("setupFileLogging", () => {
|
|
216
|
-
it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
|
|
217
|
-
const originalLog = console.log;
|
|
218
|
-
const originalError = console.error;
|
|
219
|
-
console.log = vi.fn() as never;
|
|
220
|
-
console.error = vi.fn() as never;
|
|
221
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const fileLog = setupFileLogging(dir, "index");
|
|
225
|
-
|
|
226
|
-
console.log("before flush");
|
|
227
|
-
fileLog.flush();
|
|
228
|
-
|
|
229
|
-
expect(() => console.error("after flush")).not.toThrow();
|
|
230
|
-
fileLog.flush();
|
|
231
|
-
|
|
232
|
-
const content = await readFile(fileLog.logPath, "utf8");
|
|
233
|
-
expect(content).toContain("[LOG] before flush");
|
|
234
|
-
expect(content).toContain("[ERR] after flush");
|
|
235
|
-
} finally {
|
|
236
|
-
console.log = originalLog;
|
|
237
|
-
console.error = originalError;
|
|
238
|
-
await rm(dir, { recursive: true, force: true });
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
|
|
243
|
-
const originalLog = console.log;
|
|
244
|
-
const originalError = console.error;
|
|
245
|
-
console.log = vi.fn() as never;
|
|
246
|
-
console.error = vi.fn() as never;
|
|
247
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
const fileLog = setupFileLogging(dir, "index");
|
|
251
|
-
const circular: Record<string, unknown> = { name: "root" };
|
|
252
|
-
circular.self = circular;
|
|
253
|
-
|
|
254
|
-
expect(() => console.log("circular", circular)).not.toThrow();
|
|
255
|
-
fileLog.flush();
|
|
256
|
-
|
|
257
|
-
const content = await readFile(fileLog.logPath, "utf8");
|
|
258
|
-
expect(content).toContain("[LOG] circular");
|
|
259
|
-
expect(content).toContain("self");
|
|
260
|
-
} finally {
|
|
261
|
-
console.log = originalLog;
|
|
262
|
-
console.error = originalError;
|
|
263
|
-
await rm(dir, { recursive: true, force: true });
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("setupFileLogging", () => {
|
|
216
|
+
it("flush 后继续写日志不会触发 write after end,且日志已落盘", async () => {
|
|
217
|
+
const originalLog = console.log;
|
|
218
|
+
const originalError = console.error;
|
|
219
|
+
console.log = vi.fn() as never;
|
|
220
|
+
console.error = vi.fn() as never;
|
|
221
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
225
|
+
|
|
226
|
+
console.log("before flush");
|
|
227
|
+
fileLog.flush();
|
|
228
|
+
|
|
229
|
+
expect(() => console.error("after flush")).not.toThrow();
|
|
230
|
+
fileLog.flush();
|
|
231
|
+
|
|
232
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
233
|
+
expect(content).toContain("[LOG] before flush");
|
|
234
|
+
expect(content).toContain("[ERR] after flush");
|
|
235
|
+
} finally {
|
|
236
|
+
console.log = originalLog;
|
|
237
|
+
console.error = originalError;
|
|
238
|
+
await rm(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("日志参数无法 JSON 序列化时也不会让 console 调用抛错", async () => {
|
|
243
|
+
const originalLog = console.log;
|
|
244
|
+
const originalError = console.error;
|
|
245
|
+
console.log = vi.fn() as never;
|
|
246
|
+
console.error = vi.fn() as never;
|
|
247
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-log-"));
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const fileLog = setupFileLogging(dir, "index");
|
|
251
|
+
const circular: Record<string, unknown> = { name: "root" };
|
|
252
|
+
circular.self = circular;
|
|
253
|
+
|
|
254
|
+
expect(() => console.log("circular", circular)).not.toThrow();
|
|
255
|
+
fileLog.flush();
|
|
256
|
+
|
|
257
|
+
const content = await readFile(fileLog.logPath, "utf8");
|
|
258
|
+
expect(content).toContain("[LOG] circular");
|
|
259
|
+
expect(content).toContain("self");
|
|
260
|
+
} finally {
|
|
261
|
+
console.log = originalLog;
|
|
262
|
+
console.error = originalError;
|
|
263
|
+
await rm(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{"type":"thread.started","thread_id":"019e15c7-t001-7901-b92c-9a4371e07750"}
|
|
2
|
-
{"type":"turn.started"}
|
|
3
|
-
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}
|
|
4
|
-
{"type":"turn.completed","usage":{"input_tokens":12964,"cached_input_tokens":11136,"output_tokens":5,"reasoning_output_tokens":0}}
|
|
1
|
+
{"type":"thread.started","thread_id":"019e15c7-t001-7901-b92c-9a4371e07750"}
|
|
2
|
+
{"type":"turn.started"}
|
|
3
|
+
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}
|
|
4
|
+
{"type":"turn.completed","usage":{"input_tokens":12964,"cached_input_tokens":11136,"output_tokens":5,"reasoning_output_tokens":0}}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{"type":"thread.started","thread_id":"019e15c8-t002-7441-9bfb-eec7b1ef4c4c"}
|
|
2
|
-
{"type":"turn.started"}
|
|
3
|
-
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
|
4
|
-
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"tool_test\r\n","exit_code":0,"status":"completed"}}
|
|
5
|
-
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"tool_test"}}
|
|
6
|
-
{"type":"turn.completed","usage":{"input_tokens":26021,"cached_input_tokens":22272,"output_tokens":52,"reasoning_output_tokens":0}}
|
|
1
|
+
{"type":"thread.started","thread_id":"019e15c8-t002-7441-9bfb-eec7b1ef4c4c"}
|
|
2
|
+
{"type":"turn.started"}
|
|
3
|
+
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"","exit_code":null,"status":"in_progress"}}
|
|
4
|
+
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"powershell.exe -Command echo tool_test","aggregated_output":"tool_test\r\n","exit_code":0,"status":"completed"}}
|
|
5
|
+
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"tool_test"}}
|
|
6
|
+
{"type":"turn.completed","usage":{"input_tokens":26021,"cached_input_tokens":22272,"output_tokens":52,"reasoning_output_tokens":0}}
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { buildImSkillsPrompt } from "../im-skills.ts";
|
|
7
|
-
|
|
8
|
-
let tempRoot: string | null = null;
|
|
9
|
-
|
|
10
|
-
describe("IM skills prompt rendering", () => {
|
|
11
|
-
afterEach(async () => {
|
|
12
|
-
if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
|
|
13
|
-
tempRoot = null;
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("loads skill.md files and renders runtime variables", async () => {
|
|
17
|
-
tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-"));
|
|
18
|
-
const skillDir = join(tempRoot, "feishu-skill");
|
|
19
|
-
await mkdir(skillDir);
|
|
20
|
-
await writeFile(
|
|
21
|
-
join(skillDir, "skill.md"),
|
|
22
|
-
[
|
|
23
|
-
"---",
|
|
24
|
-
"name: feishu-skill",
|
|
25
|
-
"---",
|
|
26
|
-
"GET {{session_grants_url}}?sid={{session_id}}",
|
|
27
|
-
"cwd={{cwd}}",
|
|
28
|
-
].join("\n"),
|
|
29
|
-
"utf-8",
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const prompt = await buildImSkillsPrompt({
|
|
33
|
-
skillsDir: tempRoot,
|
|
34
|
-
variables: {
|
|
35
|
-
cwd: "C:/work",
|
|
36
|
-
session_grants_url: "http://127.0.0.1:18080/api/agent/session-grants",
|
|
37
|
-
session_id: "sid_test",
|
|
38
|
-
},
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
|
|
42
|
-
expect(prompt).toContain("GET http://127.0.0.1:18080/api/agent/session-grants?sid=sid_test");
|
|
43
|
-
expect(prompt).toContain("cwd=C:/work");
|
|
44
|
-
expect(prompt).not.toContain("{{");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
1
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { buildImSkillsPrompt } from "../im-skills.ts";
|
|
7
|
+
|
|
8
|
+
let tempRoot: string | null = null;
|
|
9
|
+
|
|
10
|
+
describe("IM skills prompt rendering", () => {
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
|
|
13
|
+
tempRoot = null;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("loads skill.md files and renders runtime variables", async () => {
|
|
17
|
+
tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-"));
|
|
18
|
+
const skillDir = join(tempRoot, "feishu-skill");
|
|
19
|
+
await mkdir(skillDir);
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(skillDir, "skill.md"),
|
|
22
|
+
[
|
|
23
|
+
"---",
|
|
24
|
+
"name: feishu-skill",
|
|
25
|
+
"---",
|
|
26
|
+
"GET {{session_grants_url}}?sid={{session_id}}",
|
|
27
|
+
"cwd={{cwd}}",
|
|
28
|
+
].join("\n"),
|
|
29
|
+
"utf-8",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const prompt = await buildImSkillsPrompt({
|
|
33
|
+
skillsDir: tempRoot,
|
|
34
|
+
variables: {
|
|
35
|
+
cwd: "C:/work",
|
|
36
|
+
session_grants_url: "http://127.0.0.1:18080/api/agent/session-grants",
|
|
37
|
+
session_id: "sid_test",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
|
|
42
|
+
expect(prompt).toContain("GET http://127.0.0.1:18080/api/agent/session-grants?sid=sid_test");
|
|
43
|
+
expect(prompt).toContain("cwd=C:/work");
|
|
44
|
+
expect(prompt).not.toContain("{{");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -4,12 +4,12 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// mock stream-state 以支持在测试中控制累积长度
|
|
7
|
-
const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string }>();
|
|
7
|
+
const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string; status?: "running" | "done" | "stopped" }>();
|
|
8
8
|
vi.mock("../stream-state.ts", () => ({
|
|
9
9
|
readStreamState: async (sid: string) => {
|
|
10
10
|
const state = mockStreamStates.get(sid);
|
|
11
11
|
if (!state) return null;
|
|
12
|
-
return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
|
|
12
|
+
return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: state.status ?? "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
|
|
13
13
|
},
|
|
14
14
|
writeStreamState: async () => {},
|
|
15
15
|
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
_resetSessionToolsFileForTest,
|
|
39
39
|
_setAdapterForToolForTest,
|
|
40
40
|
_clearAdapterCacheForTest,
|
|
41
|
+
setSessionPlatform,
|
|
42
|
+
recordChatPlatform,
|
|
43
|
+
_getPlatformForChatForTest,
|
|
44
|
+
ensureDisplayLoop,
|
|
41
45
|
} from "../session.ts";
|
|
42
46
|
import {
|
|
43
47
|
activePrompts,
|
|
@@ -52,6 +56,7 @@ import {
|
|
|
52
56
|
} from "../session-chat-binding.ts";
|
|
53
57
|
import type { AccumulatorState } from "../session.ts";
|
|
54
58
|
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
59
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
55
60
|
|
|
56
61
|
// Helper to create a mock active session entry
|
|
57
62
|
function mockActiveSession(chatId: string, overrides: Partial<{
|
|
@@ -116,6 +121,23 @@ function mockAdapter(getInfo: (sid: string) => SessionInfo | undefined): ToolAda
|
|
|
116
121
|
};
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
function mockPlatform(name: string): PlatformAdapter {
|
|
125
|
+
return {
|
|
126
|
+
sendText: vi.fn(async () => true),
|
|
127
|
+
sendCard: vi.fn(async () => true),
|
|
128
|
+
sendRawCard: vi.fn(async () => true),
|
|
129
|
+
createGroup: vi.fn(async () => `${name}-group`),
|
|
130
|
+
updateChatInfo: vi.fn(async () => {}),
|
|
131
|
+
getChatInfo: vi.fn(async () => ({ name, description: "" })),
|
|
132
|
+
disbandChat: vi.fn(async () => {}),
|
|
133
|
+
setChatAvatar: vi.fn(async () => {}),
|
|
134
|
+
extractSessionInfo: vi.fn(() => null),
|
|
135
|
+
cardCreate: vi.fn(async () => `${name}-card`),
|
|
136
|
+
cardSend: vi.fn(async () => `${name}-message`),
|
|
137
|
+
cardUpdate: vi.fn(async () => {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
119
141
|
describe("resetState", () => {
|
|
120
142
|
it("clears all maps and sets", () => {
|
|
121
143
|
chatSessionMap.set("chat1", {
|
|
@@ -137,6 +159,23 @@ describe("resetState", () => {
|
|
|
137
159
|
});
|
|
138
160
|
});
|
|
139
161
|
|
|
162
|
+
describe("chat platform routing", () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
resetState();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("uses the platform recorded for the chat before falling back to the default platform", () => {
|
|
168
|
+
const feishu = mockPlatform("feishu");
|
|
169
|
+
const wechat = mockPlatform("wechat");
|
|
170
|
+
|
|
171
|
+
setSessionPlatform(feishu);
|
|
172
|
+
recordChatPlatform("wx-chat", wechat);
|
|
173
|
+
|
|
174
|
+
expect(_getPlatformForChatForTest("wx-chat")).toBe(wechat);
|
|
175
|
+
expect(_getPlatformForChatForTest("feishu-chat")).toBe(feishu);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
140
179
|
// ---------------------------------------------------------------------------
|
|
141
180
|
// rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
|
|
142
181
|
//
|
|
@@ -145,6 +184,60 @@ describe("resetState", () => {
|
|
|
145
184
|
// processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
|
|
146
185
|
// ---------------------------------------------------------------------------
|
|
147
186
|
|
|
187
|
+
describe("ensureDisplayLoop WeChat delta", () => {
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
vi.useFakeTimers();
|
|
190
|
+
resetState();
|
|
191
|
+
resetBindingState();
|
|
192
|
+
mockStreamStates.clear();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
resetBindingState();
|
|
197
|
+
vi.useRealTimers();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
|
|
201
|
+
const platform = mockPlatform("wechat");
|
|
202
|
+
platform.kind = "wechat";
|
|
203
|
+
setSessionPlatform(platform);
|
|
204
|
+
|
|
205
|
+
bindChatToSession("sid-wechat", "chat-wechat");
|
|
206
|
+
recordLastActiveChat("sid-wechat", "chat-wechat");
|
|
207
|
+
sessionInfoMap.set("chat-wechat", {
|
|
208
|
+
sessionId: "sid-wechat",
|
|
209
|
+
turnCount: 1,
|
|
210
|
+
lastContextTokens: 0,
|
|
211
|
+
startTime: 0,
|
|
212
|
+
tool: "claude",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
mockStreamStates.set("sid-wechat", {
|
|
216
|
+
accumulatedContent: "",
|
|
217
|
+
finalReply: "partial reply",
|
|
218
|
+
});
|
|
219
|
+
ensureDisplayLoop("sid-wechat");
|
|
220
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
221
|
+
|
|
222
|
+
mockStreamStates.set("sid-wechat", {
|
|
223
|
+
accumulatedContent: "tool output\n",
|
|
224
|
+
finalReply: "partial reply",
|
|
225
|
+
});
|
|
226
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
227
|
+
|
|
228
|
+
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
229
|
+
1,
|
|
230
|
+
"chat-wechat",
|
|
231
|
+
"partial reply",
|
|
232
|
+
);
|
|
233
|
+
expect(platform.sendText).toHaveBeenNthCalledWith(
|
|
234
|
+
2,
|
|
235
|
+
"chat-wechat",
|
|
236
|
+
"tool output",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
148
241
|
describe("rebuildBindingsFromRegistry", () => {
|
|
149
242
|
let registryFile = "";
|
|
150
243
|
|