chatccc 0.2.190 → 0.2.192
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/agent-prompts/claude_specific.md +45 -45
- package/agent-prompts/codex_specific.md +2 -2
- package/agent-prompts/cursor_specific.md +13 -13
- package/config.sample.json +14 -14
- package/im-skills/feishu-skill/receive-send-file.md +63 -63
- package/im-skills/feishu-skill/receive-send-image.md +24 -24
- package/im-skills/feishu-skill/skill.md +3 -3
- package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
- package/im-skills/wechat-file-skill/send-file.mjs +83 -83
- package/im-skills/wechat-file-skill/skill.md +10 -10
- package/im-skills/wechat-image-skill/skill.md +10 -10
- package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
- package/im-skills/wechat-video-skill/send-video.mjs +79 -79
- package/im-skills/wechat-video-skill/skill.md +10 -10
- package/package.json +1 -1
- package/scripts/postinstall-sharp-check.mjs +58 -58
- package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
- package/src/__tests__/builtin-chat-session.test.ts +76 -0
- package/src/__tests__/builtin-config.test.ts +33 -33
- package/src/__tests__/builtin-context.test.ts +126 -0
- package/src/__tests__/builtin-sigint.test.ts +56 -0
- package/src/__tests__/card-plain-text.test.ts +5 -5
- package/src/__tests__/cardkit.test.ts +60 -60
- package/src/__tests__/cards.test.ts +77 -77
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
- package/src/__tests__/chatgpt-subscription.test.ts +135 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
- package/src/__tests__/claude-adapter.test.ts +592 -592
- package/src/__tests__/codex-reset-actions.test.ts +146 -146
- package/src/__tests__/config-reload.test.ts +4 -4
- package/src/__tests__/config-sample.test.ts +17 -17
- package/src/__tests__/feishu-api.test.ts +60 -60
- package/src/__tests__/feishu-platform.test.ts +22 -22
- package/src/__tests__/format-message.test.ts +47 -47
- package/src/__tests__/orchestrator.test.ts +120 -120
- package/src/__tests__/privacy.test.ts +198 -198
- package/src/__tests__/raw-stream-log.test.ts +106 -106
- package/src/__tests__/session.test.ts +17 -17
- package/src/__tests__/shared-prefix.test.ts +36 -36
- package/src/__tests__/stream-state.test.ts +42 -42
- package/src/__tests__/web-ui.test.ts +209 -209
- package/src/adapters/claude-adapter.ts +566 -566
- package/src/adapters/claude-session-meta-store.ts +120 -120
- package/src/adapters/codex-adapter.ts +30 -30
- package/src/adapters/cursor-adapter.ts +46 -46
- package/src/adapters/raw-stream-log.ts +124 -124
- package/src/adapters/resource-monitor.ts +140 -140
- package/src/agent-delegate-task-rpc.ts +153 -153
- package/src/agent-delegate-task.ts +81 -81
- package/src/agent-stop-stuck.ts +129 -129
- package/src/builtin/cli.ts +211 -189
- package/src/builtin/context.ts +225 -0
- package/src/builtin/index.ts +232 -170
- package/src/builtin/sigint.ts +50 -0
- package/src/cards.ts +137 -137
- package/src/chatgpt-subscription-rpc.ts +27 -27
- package/src/chatgpt-subscription.ts +299 -299
- package/src/chrome-devtools-guard.ts +318 -318
- package/src/codex-reset-actions.ts +184 -184
- package/src/config.ts +86 -86
- package/src/feishu-platform.ts +20 -20
- package/src/format-message.ts +293 -293
- package/src/litellm-proxy.ts +374 -374
- package/src/orchestrator.ts +143 -143
- package/src/privacy.ts +118 -118
- package/src/session-chat-binding.ts +6 -6
- package/src/session-name.ts +8 -8
- package/src/session.ts +98 -98
- package/src/shared-prefix.ts +29 -29
- package/src/sim-platform.ts +20 -20
- package/src/turn-cards.ts +117 -117
- package/src/web-ui.ts +205 -205
|
@@ -1,165 +1,165 @@
|
|
|
1
|
-
import { Readable } from "node:stream";
|
|
2
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
-
|
|
4
|
-
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
5
|
-
|
|
6
|
-
const mockDelegateAgentTask = vi.hoisted(() => vi.fn());
|
|
7
|
-
|
|
8
|
-
vi.mock("../agent-delegate-task.ts", () => ({
|
|
9
|
-
delegateAgentTask: mockDelegateAgentTask,
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
AGENT_DELEGATE_TASK_PATH,
|
|
14
|
-
buildAgentDelegateTaskCapabilityPrompt,
|
|
15
|
-
handleAgentDelegateTaskRequest,
|
|
16
|
-
} from "../agent-delegate-task-rpc.ts";
|
|
17
|
-
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
18
|
-
|
|
19
|
-
function request(body: unknown, path = AGENT_DELEGATE_TASK_PATH, method = "POST"): Readable & {
|
|
20
|
-
url?: string;
|
|
21
|
-
method?: string;
|
|
22
|
-
headers: Record<string, string>;
|
|
23
|
-
} {
|
|
24
|
-
const req = Readable.from([Buffer.from(JSON.stringify(body), "utf8")]) as Readable & {
|
|
25
|
-
url?: string;
|
|
26
|
-
method?: string;
|
|
27
|
-
headers: Record<string, string>;
|
|
28
|
-
};
|
|
29
|
-
req.url = path;
|
|
30
|
-
req.method = method;
|
|
31
|
-
req.headers = { "content-type": "application/json; charset=utf-8" };
|
|
32
|
-
return req;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function response() {
|
|
36
|
-
const res = {
|
|
37
|
-
statusCode: 0,
|
|
38
|
-
headers: {} as Record<string, string>,
|
|
39
|
-
body: "",
|
|
40
|
-
writeHead(status: number, headers: Record<string, string>) {
|
|
41
|
-
this.statusCode = status;
|
|
42
|
-
this.headers = headers;
|
|
43
|
-
return this;
|
|
44
|
-
},
|
|
45
|
-
end(chunk?: string) {
|
|
46
|
-
this.body += chunk ?? "";
|
|
47
|
-
return this;
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
return res;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function platform(kind: "feishu" | "wechat" = "feishu"): PlatformAdapter {
|
|
54
|
-
return {
|
|
55
|
-
kind,
|
|
56
|
-
sendText: vi.fn(async () => true),
|
|
57
|
-
sendCard: vi.fn(async () => true),
|
|
58
|
-
sendRawCard: vi.fn(async () => true),
|
|
59
|
-
createGroup: vi.fn(async () => "chat-id"),
|
|
60
|
-
updateChatInfo: vi.fn(async () => {}),
|
|
61
|
-
getChatInfo: vi.fn(async () => ({ name: "chat", description: "" })),
|
|
62
|
-
disbandChat: vi.fn(async () => {}),
|
|
63
|
-
setChatAvatar: vi.fn(async () => {}),
|
|
64
|
-
extractSessionInfo: vi.fn(() => null),
|
|
65
|
-
cardCreate: vi.fn(async () => "card-id"),
|
|
66
|
-
cardSend: vi.fn(async () => "message-id"),
|
|
67
|
-
cardUpdate: vi.fn(async () => {}),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
describe("agent delegate task RPC", () => {
|
|
72
|
-
beforeEach(() => {
|
|
73
|
-
mockDelegateAgentTask.mockReset();
|
|
74
|
-
mockDelegateAgentTask.mockResolvedValue({
|
|
75
|
-
chatId: "chat-new",
|
|
76
|
-
sessionId: "sid-new",
|
|
77
|
-
tool: "codex",
|
|
78
|
-
cwd: "F:\\repo",
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("delegates a task through the local API", async () => {
|
|
83
|
-
const req = request({
|
|
84
|
-
tool: "codex",
|
|
85
|
-
cwd: "F:\\repo",
|
|
86
|
-
open_id: "ou-user",
|
|
87
|
-
prompt: "帮我分析日志",
|
|
88
|
-
});
|
|
89
|
-
const res = response();
|
|
90
|
-
|
|
91
|
-
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
92
|
-
|
|
93
|
-
expect(res.statusCode).toBe(200);
|
|
94
|
-
expect(JSON.parse(res.body)).toMatchObject({
|
|
95
|
-
ok: true,
|
|
96
|
-
chat_id: "chat-new",
|
|
97
|
-
session_id: "sid-new",
|
|
98
|
-
tool: "codex",
|
|
99
|
-
});
|
|
100
|
-
expect(mockDelegateAgentTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
101
|
-
tool: "codex",
|
|
102
|
-
cwd: expect.stringContaining("repo"),
|
|
103
|
-
promptText: "帮我分析日志",
|
|
104
|
-
openIds: ["ou-user"],
|
|
105
|
-
chatNamePrefix: "帮我分析日志",
|
|
106
|
-
}));
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("applies the shared ABD prefix to API prompts", async () => {
|
|
110
|
-
const req = request({
|
|
111
|
-
tool: "claude",
|
|
112
|
-
cwd: "F:\\repo",
|
|
113
|
-
openIds: ["ou-user"],
|
|
114
|
-
prompt: "/abd帮我分析",
|
|
115
|
-
});
|
|
116
|
-
const res = response();
|
|
117
|
-
|
|
118
|
-
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
119
|
-
|
|
120
|
-
expect(res.statusCode).toBe(200);
|
|
121
|
-
expect(mockDelegateAgentTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
122
|
-
promptText: `帮我分析\n\n---\n${ABD_APPEND_PROMPT}`,
|
|
123
|
-
chatNamePrefix: "帮我分析",
|
|
124
|
-
}));
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("rejects non-Feishu platforms", async () => {
|
|
128
|
-
const req = request({
|
|
129
|
-
tool: "codex",
|
|
130
|
-
cwd: "F:\\repo",
|
|
131
|
-
open_id: "ou-user",
|
|
132
|
-
prompt: "task",
|
|
133
|
-
});
|
|
134
|
-
const res = response();
|
|
135
|
-
|
|
136
|
-
await handleAgentDelegateTaskRequest(req as never, res as never, platform("wechat"));
|
|
137
|
-
|
|
138
|
-
expect(res.statusCode).toBe(409);
|
|
139
|
-
expect(mockDelegateAgentTask).not.toHaveBeenCalled();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("rejects missing required parameters", async () => {
|
|
143
|
-
const req = request({ tool: "codex", cwd: "F:\\repo", prompt: "task" });
|
|
144
|
-
const res = response();
|
|
145
|
-
|
|
146
|
-
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
147
|
-
|
|
148
|
-
expect(res.statusCode).toBe(400);
|
|
149
|
-
expect(JSON.parse(res.body).error).toContain("open_id");
|
|
150
|
-
expect(mockDelegateAgentTask).not.toHaveBeenCalled();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("builds prompt instructions for the delegate endpoint", () => {
|
|
154
|
-
const prompt = buildAgentDelegateTaskCapabilityPrompt({
|
|
155
|
-
url: `http://127.0.0.1:18080${AGENT_DELEGATE_TASK_PATH}`,
|
|
156
|
-
cwd: "F:/repo",
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/delegate-task");
|
|
160
|
-
expect(prompt).toContain('"tool":"codex|claude|cursor"');
|
|
161
|
-
expect(prompt).toContain('"cwd":"absolute working directory"');
|
|
162
|
-
expect(prompt).toContain("project prompt injection and IM skills still apply");
|
|
163
|
-
expect(prompt).toContain("Current working directory: F:/repo");
|
|
164
|
-
});
|
|
165
|
-
});
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
5
|
+
|
|
6
|
+
const mockDelegateAgentTask = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("../agent-delegate-task.ts", () => ({
|
|
9
|
+
delegateAgentTask: mockDelegateAgentTask,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AGENT_DELEGATE_TASK_PATH,
|
|
14
|
+
buildAgentDelegateTaskCapabilityPrompt,
|
|
15
|
+
handleAgentDelegateTaskRequest,
|
|
16
|
+
} from "../agent-delegate-task-rpc.ts";
|
|
17
|
+
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
18
|
+
|
|
19
|
+
function request(body: unknown, path = AGENT_DELEGATE_TASK_PATH, method = "POST"): Readable & {
|
|
20
|
+
url?: string;
|
|
21
|
+
method?: string;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
} {
|
|
24
|
+
const req = Readable.from([Buffer.from(JSON.stringify(body), "utf8")]) as Readable & {
|
|
25
|
+
url?: string;
|
|
26
|
+
method?: string;
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
req.url = path;
|
|
30
|
+
req.method = method;
|
|
31
|
+
req.headers = { "content-type": "application/json; charset=utf-8" };
|
|
32
|
+
return req;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function response() {
|
|
36
|
+
const res = {
|
|
37
|
+
statusCode: 0,
|
|
38
|
+
headers: {} as Record<string, string>,
|
|
39
|
+
body: "",
|
|
40
|
+
writeHead(status: number, headers: Record<string, string>) {
|
|
41
|
+
this.statusCode = status;
|
|
42
|
+
this.headers = headers;
|
|
43
|
+
return this;
|
|
44
|
+
},
|
|
45
|
+
end(chunk?: string) {
|
|
46
|
+
this.body += chunk ?? "";
|
|
47
|
+
return this;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function platform(kind: "feishu" | "wechat" = "feishu"): PlatformAdapter {
|
|
54
|
+
return {
|
|
55
|
+
kind,
|
|
56
|
+
sendText: vi.fn(async () => true),
|
|
57
|
+
sendCard: vi.fn(async () => true),
|
|
58
|
+
sendRawCard: vi.fn(async () => true),
|
|
59
|
+
createGroup: vi.fn(async () => "chat-id"),
|
|
60
|
+
updateChatInfo: vi.fn(async () => {}),
|
|
61
|
+
getChatInfo: vi.fn(async () => ({ name: "chat", description: "" })),
|
|
62
|
+
disbandChat: vi.fn(async () => {}),
|
|
63
|
+
setChatAvatar: vi.fn(async () => {}),
|
|
64
|
+
extractSessionInfo: vi.fn(() => null),
|
|
65
|
+
cardCreate: vi.fn(async () => "card-id"),
|
|
66
|
+
cardSend: vi.fn(async () => "message-id"),
|
|
67
|
+
cardUpdate: vi.fn(async () => {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("agent delegate task RPC", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
mockDelegateAgentTask.mockReset();
|
|
74
|
+
mockDelegateAgentTask.mockResolvedValue({
|
|
75
|
+
chatId: "chat-new",
|
|
76
|
+
sessionId: "sid-new",
|
|
77
|
+
tool: "codex",
|
|
78
|
+
cwd: "F:\\repo",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("delegates a task through the local API", async () => {
|
|
83
|
+
const req = request({
|
|
84
|
+
tool: "codex",
|
|
85
|
+
cwd: "F:\\repo",
|
|
86
|
+
open_id: "ou-user",
|
|
87
|
+
prompt: "帮我分析日志",
|
|
88
|
+
});
|
|
89
|
+
const res = response();
|
|
90
|
+
|
|
91
|
+
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
92
|
+
|
|
93
|
+
expect(res.statusCode).toBe(200);
|
|
94
|
+
expect(JSON.parse(res.body)).toMatchObject({
|
|
95
|
+
ok: true,
|
|
96
|
+
chat_id: "chat-new",
|
|
97
|
+
session_id: "sid-new",
|
|
98
|
+
tool: "codex",
|
|
99
|
+
});
|
|
100
|
+
expect(mockDelegateAgentTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
101
|
+
tool: "codex",
|
|
102
|
+
cwd: expect.stringContaining("repo"),
|
|
103
|
+
promptText: "帮我分析日志",
|
|
104
|
+
openIds: ["ou-user"],
|
|
105
|
+
chatNamePrefix: "帮我分析日志",
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("applies the shared ABD prefix to API prompts", async () => {
|
|
110
|
+
const req = request({
|
|
111
|
+
tool: "claude",
|
|
112
|
+
cwd: "F:\\repo",
|
|
113
|
+
openIds: ["ou-user"],
|
|
114
|
+
prompt: "/abd帮我分析",
|
|
115
|
+
});
|
|
116
|
+
const res = response();
|
|
117
|
+
|
|
118
|
+
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
119
|
+
|
|
120
|
+
expect(res.statusCode).toBe(200);
|
|
121
|
+
expect(mockDelegateAgentTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
122
|
+
promptText: `帮我分析\n\n---\n${ABD_APPEND_PROMPT}`,
|
|
123
|
+
chatNamePrefix: "帮我分析",
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects non-Feishu platforms", async () => {
|
|
128
|
+
const req = request({
|
|
129
|
+
tool: "codex",
|
|
130
|
+
cwd: "F:\\repo",
|
|
131
|
+
open_id: "ou-user",
|
|
132
|
+
prompt: "task",
|
|
133
|
+
});
|
|
134
|
+
const res = response();
|
|
135
|
+
|
|
136
|
+
await handleAgentDelegateTaskRequest(req as never, res as never, platform("wechat"));
|
|
137
|
+
|
|
138
|
+
expect(res.statusCode).toBe(409);
|
|
139
|
+
expect(mockDelegateAgentTask).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects missing required parameters", async () => {
|
|
143
|
+
const req = request({ tool: "codex", cwd: "F:\\repo", prompt: "task" });
|
|
144
|
+
const res = response();
|
|
145
|
+
|
|
146
|
+
await handleAgentDelegateTaskRequest(req as never, res as never, platform());
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(400);
|
|
149
|
+
expect(JSON.parse(res.body).error).toContain("open_id");
|
|
150
|
+
expect(mockDelegateAgentTask).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("builds prompt instructions for the delegate endpoint", () => {
|
|
154
|
+
const prompt = buildAgentDelegateTaskCapabilityPrompt({
|
|
155
|
+
url: `http://127.0.0.1:18080${AGENT_DELEGATE_TASK_PATH}`,
|
|
156
|
+
cwd: "F:/repo",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/delegate-task");
|
|
160
|
+
expect(prompt).toContain('"tool":"codex|claude|cursor"');
|
|
161
|
+
expect(prompt).toContain('"cwd":"absolute working directory"');
|
|
162
|
+
expect(prompt).toContain("project prompt injection and IM skills still apply");
|
|
163
|
+
expect(prompt).toContain("Current working directory: F:/repo");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { ChatSession } from "../builtin/index.ts";
|
|
4
|
-
import { config } from "../config.ts";
|
|
5
|
-
|
|
6
|
-
const originalDeepSeekApiKey = process.env.DEEPSEEK_API_KEY;
|
|
7
|
-
const originalCccConfig = structuredClone(config.ccc);
|
|
8
|
-
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
if (originalDeepSeekApiKey === undefined) {
|
|
11
|
-
delete process.env.DEEPSEEK_API_KEY;
|
|
12
|
-
} else {
|
|
13
|
-
process.env.DEEPSEEK_API_KEY = originalDeepSeekApiKey;
|
|
14
|
-
}
|
|
15
|
-
config.ccc = structuredClone(originalCccConfig);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("builtin ChatSession config", () => {
|
|
19
|
-
it("does not fall back to DEEPSEEK_API_KEY environment variable", () => {
|
|
20
|
-
config.ccc = {
|
|
21
|
-
DEEPSEEK_API_KEY: "",
|
|
22
|
-
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
23
|
-
model: "deepseek-v4-pro",
|
|
24
|
-
};
|
|
25
|
-
process.env.DEEPSEEK_API_KEY = "sk-env-should-not-be-used";
|
|
26
|
-
|
|
27
|
-
expect(() => new ChatSession()).toThrow("ccc.DEEPSEEK_API_KEY 未设置");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("allows constructor parameters to override config defaults", () => {
|
|
31
|
-
expect(() => new ChatSession({ apiKey: "sk-test" })).not.toThrow();
|
|
32
|
-
});
|
|
33
|
-
});
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ChatSession } from "../builtin/index.ts";
|
|
4
|
+
import { config } from "../config.ts";
|
|
5
|
+
|
|
6
|
+
const originalDeepSeekApiKey = process.env.DEEPSEEK_API_KEY;
|
|
7
|
+
const originalCccConfig = structuredClone(config.ccc);
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalDeepSeekApiKey === undefined) {
|
|
11
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
12
|
+
} else {
|
|
13
|
+
process.env.DEEPSEEK_API_KEY = originalDeepSeekApiKey;
|
|
14
|
+
}
|
|
15
|
+
config.ccc = structuredClone(originalCccConfig);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("builtin ChatSession config", () => {
|
|
19
|
+
it("does not fall back to DEEPSEEK_API_KEY environment variable", () => {
|
|
20
|
+
config.ccc = {
|
|
21
|
+
DEEPSEEK_API_KEY: "",
|
|
22
|
+
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
23
|
+
model: "deepseek-v4-pro",
|
|
24
|
+
};
|
|
25
|
+
process.env.DEEPSEEK_API_KEY = "sk-env-should-not-be-used";
|
|
26
|
+
|
|
27
|
+
expect(() => new ChatSession()).toThrow("ccc.DEEPSEEK_API_KEY 未设置");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("allows constructor parameters to override config defaults", () => {
|
|
31
|
+
expect(() => new ChatSession({ apiKey: "sk-test" })).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
serializeMessagesForSummary,
|
|
11
|
+
} from "../builtin/context.ts";
|
|
12
|
+
|
|
13
|
+
describe("BuiltinContextManager", () => {
|
|
14
|
+
it("persists and restores summary, messages, and total message count", async () => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-context-"));
|
|
16
|
+
|
|
17
|
+
const first = new BuiltinContextManager({
|
|
18
|
+
persist: true,
|
|
19
|
+
contextDir: dir,
|
|
20
|
+
sessionId: "persisted",
|
|
21
|
+
});
|
|
22
|
+
first.setSummary("## 用户目标\n- 保留这个摘要");
|
|
23
|
+
first.appendMessage({ role: "user", content: "第一轮" });
|
|
24
|
+
first.appendMessage({ role: "assistant", content: "第一轮回复" });
|
|
25
|
+
first.save();
|
|
26
|
+
|
|
27
|
+
const restored = new BuiltinContextManager({
|
|
28
|
+
persist: true,
|
|
29
|
+
contextDir: dir,
|
|
30
|
+
sessionId: "persisted",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(restored.summary).toContain("保留这个摘要");
|
|
34
|
+
expect(restored.messages).toEqual([
|
|
35
|
+
{ role: "user", content: "第一轮" },
|
|
36
|
+
{ role: "assistant", content: "第一轮回复" },
|
|
37
|
+
]);
|
|
38
|
+
expect(restored.totalMessages).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("selects only older messages for compaction and keeps recent messages raw", () => {
|
|
42
|
+
const context = new BuiltinContextManager({
|
|
43
|
+
compactAtTokens: 1,
|
|
44
|
+
keepRecentMessages: 2,
|
|
45
|
+
persist: false,
|
|
46
|
+
});
|
|
47
|
+
context.appendMessage({ role: "user", content: "旧用户消息" });
|
|
48
|
+
context.appendMessage({ role: "assistant", content: "旧助手回复" });
|
|
49
|
+
context.appendMessage({ role: "user", content: "近期用户消息" });
|
|
50
|
+
context.appendMessage({ role: "assistant", content: "近期助手回复" });
|
|
51
|
+
|
|
52
|
+
const plan = context.planCompaction();
|
|
53
|
+
|
|
54
|
+
expect(plan).not.toBeNull();
|
|
55
|
+
expect(plan?.oldMessages).toEqual([
|
|
56
|
+
{ role: "user", content: "旧用户消息" },
|
|
57
|
+
{ role: "assistant", content: "旧助手回复" },
|
|
58
|
+
]);
|
|
59
|
+
expect(plan?.recentMessages).toEqual([
|
|
60
|
+
{ role: "user", content: "近期用户消息" },
|
|
61
|
+
{ role: "assistant", content: "近期助手回复" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("applies a compacted summary and builds model messages with summary plus recent raw turns", () => {
|
|
66
|
+
const context = new BuiltinContextManager({
|
|
67
|
+
compactAtTokens: 1,
|
|
68
|
+
keepRecentMessages: 1,
|
|
69
|
+
persist: false,
|
|
70
|
+
});
|
|
71
|
+
context.appendMessage({ role: "user", content: "old" });
|
|
72
|
+
context.appendMessage({ role: "assistant", content: "recent" });
|
|
73
|
+
|
|
74
|
+
const plan = context.planCompaction();
|
|
75
|
+
expect(plan).not.toBeNull();
|
|
76
|
+
context.applyCompaction("## 当前任务状态\n- 已压缩旧上下文", plan!);
|
|
77
|
+
|
|
78
|
+
expect(context.summary).toContain("已压缩旧上下文");
|
|
79
|
+
expect(context.messages).toEqual([{ role: "assistant", content: "recent" }]);
|
|
80
|
+
expect(context.buildModelMessages()).toEqual([
|
|
81
|
+
{
|
|
82
|
+
role: "user",
|
|
83
|
+
content: expect.stringContaining("较早对话摘要"),
|
|
84
|
+
},
|
|
85
|
+
{ role: "assistant", content: "recent" },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("reset clears memory and the persisted context file", async () => {
|
|
90
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-builtin-context-reset-"));
|
|
91
|
+
const context = new BuiltinContextManager({
|
|
92
|
+
persist: true,
|
|
93
|
+
contextDir: dir,
|
|
94
|
+
sessionId: "reset",
|
|
95
|
+
});
|
|
96
|
+
context.setSummary("summary");
|
|
97
|
+
context.appendMessage({ role: "user", content: "hello" });
|
|
98
|
+
context.save();
|
|
99
|
+
|
|
100
|
+
context.reset();
|
|
101
|
+
|
|
102
|
+
expect(context.summary).toBe("");
|
|
103
|
+
expect(context.messages).toEqual([]);
|
|
104
|
+
expect(context.totalMessages).toBe(0);
|
|
105
|
+
|
|
106
|
+
const raw = await readFile(join(dir, "reset", "context.json"), "utf8");
|
|
107
|
+
const persisted = JSON.parse(raw) as { summary: string; messages: unknown[]; totalMessages: number };
|
|
108
|
+
expect(persisted.summary).toBe("");
|
|
109
|
+
expect(persisted.messages).toEqual([]);
|
|
110
|
+
expect(persisted.totalMessages).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("builtin context helpers", () => {
|
|
115
|
+
it("estimates tokens from summary and messages", () => {
|
|
116
|
+
expect(estimateBuiltinContextTokens("abc", [{ role: "user", content: "abcdef" }]))
|
|
117
|
+
.toBeGreaterThanOrEqual(3);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("serializes messages for summarization with stable role labels", () => {
|
|
121
|
+
expect(serializeMessagesForSummary([
|
|
122
|
+
{ role: "user", content: "你好" },
|
|
123
|
+
{ role: "assistant", content: "收到" },
|
|
124
|
+
])).toContain("user");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -16,11 +16,11 @@ describe("cardJsonToPlainText", () => {
|
|
|
16
16
|
expect(text).toContain("/new");
|
|
17
17
|
expect(text).toContain("/new cursor");
|
|
18
18
|
expect(text).toContain("/new codex");
|
|
19
|
-
expect(text).toContain("/restart");
|
|
20
|
-
expect(text).toContain("/update");
|
|
21
|
-
expect(text).toContain("/cd");
|
|
22
|
-
expect(text).toContain("/abd");
|
|
23
|
-
});
|
|
19
|
+
expect(text).toContain("/restart");
|
|
20
|
+
expect(text).toContain("/update");
|
|
21
|
+
expect(text).toContain("/cd");
|
|
22
|
+
expect(text).toContain("/abd");
|
|
23
|
+
});
|
|
24
24
|
|
|
25
25
|
it("converts status cards from v1 card format", () => {
|
|
26
26
|
const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
|