chatccc 0.2.10 → 0.2.11
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 +63 -11
- package/package.json +4 -1
- package/src/__tests__/cards.test.ts +1 -1
- package/src/__tests__/codex-adapter.test.ts +305 -0
- package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -0
- package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -0
- package/src/adapters/codex-adapter.ts +294 -0
- package/src/adapters/codex-session-meta-store.ts +131 -0
- package/src/cards.ts +28 -7
- package/src/config.ts +4 -0
- package/src/feishu-api.ts +115 -16
- package/src/index.ts +98 -20
- package/src/session.ts +28 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ChatCCC
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**飞书(Lark)聊天控制 Claude Code / Cursor / Codex**
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
传统的 Claude Code(尤其是使用第三方 API 的,如 DeepSeek),需要坐在电脑桌前才能用。**离开电脑就没法用了。**
|
|
10
10
|
|
|
11
|
-
ChatCCC 把 Claude Code
|
|
11
|
+
ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
12
12
|
|
|
13
|
-
- **手机上也能用 Claude Code / Cursor** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
|
|
13
|
+
- **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
|
|
14
14
|
- **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
|
|
15
|
-
- **多工具切换** —— `/new claude` 创建 Claude Code 会话,`/new cursor` 创建 Cursor 会话,各取所长
|
|
15
|
+
- **多工具切换** —— `/new claude` 创建 Claude Code 会话,`/new cursor` 创建 Cursor 会话,`/new codex` 创建 Codex 会话,各取所长
|
|
16
16
|
|
|
17
|
-
一句话:**在任何设备上打开飞书,就能让 Claude Code
|
|
17
|
+
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
20
|
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="280" />
|
|
@@ -29,7 +29,7 @@ ChatCCC 把 Claude Code 和 Cursor Agent 接入了飞书群聊:
|
|
|
29
29
|
- **一群一会话,心智最简单** —— `/new` 直接建一个新飞书群,群本身就是 AI 会话上下文。换会话就是换群,没有 thread / 子话题概念,手机端切换最直观
|
|
30
30
|
- **零配置成本** —— 只用 `.env`,没有 TOML、没有 Web 后台。`npm i -g chatccc` 后 `cd` 到项目目录直接 `chatccc` 就跑
|
|
31
31
|
- **群里能跑 git** —— `/git status`、`/git pull`、`/git log` 在飞书群里直接执行 stdout/stderr 回发,不用回电脑
|
|
32
|
-
- **代码极简易改** —— 纯 TypeScript 实现,核心只有 20 多个文件,统一 `ToolAdapter` 接口屏蔽 Claude / Cursor 差异,看得懂、改得动
|
|
32
|
+
- **代码极简易改** —— 纯 TypeScript 实现,核心只有 20 多个文件,统一 `ToolAdapter` 接口屏蔽 Claude / Cursor / Codex 差异,看得懂、改得动
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
|
|
@@ -112,18 +112,55 @@ CHATCCC_CURSOR_COMMAND=/path/to/agent
|
|
|
112
112
|
|
|
113
113
|
> **说明**:只使用 Claude Code(`/new claude` 或 `/new`)的用户无需安装 Cursor CLI。
|
|
114
114
|
|
|
115
|
+
#### Codex CLI(使用 Codex 会话时需要)
|
|
116
|
+
|
|
117
|
+
ChatCCC **不捆绑** Codex CLI,需要用户自行安装:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm install -g @openai/codex
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
安装后需登录 OpenAI 账号:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
codex login
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
验证是否已安装:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
codex --version
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Codex 会话的模型和努力程度由 `config.toml`(`~/.codex/config.toml`)决定,也可通过环境变量指定:
|
|
136
|
+
|
|
137
|
+
```env
|
|
138
|
+
# 不设置或设为 default 时由 codex config.toml 决定
|
|
139
|
+
CHATCCC_CODEX_MODEL=default
|
|
140
|
+
|
|
141
|
+
# 不设置或设为 default 时由 codex config.toml 决定(通过 -c model_reasoning_effort 传递)
|
|
142
|
+
CHATCCC_CODEX_EFFORT=default
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
也可通过环境变量指定自定义 Codex 可执行文件路径:
|
|
146
|
+
|
|
147
|
+
```env
|
|
148
|
+
CHATCCC_CODEX_COMMAND=/path/to/codex
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
> **说明**:只使用 Claude Code 或 Cursor 的用户无需安装 Codex CLI。
|
|
152
|
+
|
|
115
153
|
### 2. 创建飞书应用
|
|
116
154
|
|
|
117
155
|
打开 [飞书开放平台](https://open.feishu.cn),创建一个**企业自建应用**。
|
|
118
156
|
|
|
119
157
|
**添加应用能力**:开启「机器人」功能。
|
|
120
158
|
|
|
121
|
-
|
|
159
|
+
**权限配置(重要)**(在「权限管理」中按前缀搜索,**将以下两类前缀开头的权限全部开通**):
|
|
122
160
|
|
|
123
161
|
| 前缀 | 用途(简要) |
|
|
124
162
|
| ---- | ------------ |
|
|
125
|
-
| `im
|
|
126
|
-
| `im:chat` | 创建与管理群聊、与会话绑定、群成员与聊天场景相关能力等 |
|
|
163
|
+
| `im:` | 收发消息、创建与管理群聊、以机器人身份发言等(请将此前缀下所有权限全部开通) |
|
|
127
164
|
| `cardkit:` | 群卡片展示、流式更新、卡片按钮与交互回调相关能力等 |
|
|
128
165
|
|
|
129
166
|
控制台中同一前缀下往往有多条子权限,请逐项勾选开通,避免遗漏导致收不到消息或卡片异常。
|
|
@@ -182,6 +219,19 @@ CHATCCC_CURSOR_MODEL=claude-opus-4-7-max
|
|
|
182
219
|
# CHATCCC_CURSOR_ARGS=-p --force --output-format stream-json --stream-partial-output
|
|
183
220
|
```
|
|
184
221
|
|
|
222
|
+
Codex CLI 模型与努力程度(仅 Codex 会话相关,省略或 `default` 时不传对应参数):
|
|
223
|
+
|
|
224
|
+
```env
|
|
225
|
+
# Codex 会话使用的模型;不设置或设为 default 时由 codex config.toml 决定
|
|
226
|
+
CHATCCC_CODEX_MODEL=default
|
|
227
|
+
|
|
228
|
+
# Codex 会话的努力程度(low/medium/high);不设置或设为 default 时由 codex config.toml 决定
|
|
229
|
+
CHATCCC_CODEX_EFFORT=default
|
|
230
|
+
|
|
231
|
+
# 也可指定自定义 Codex 可执行文件路径
|
|
232
|
+
# CHATCCC_CODEX_COMMAND=/path/to/codex
|
|
233
|
+
```
|
|
234
|
+
|
|
185
235
|
#### 注意!第三方 API(如 DeepSeek)与鉴权 403
|
|
186
236
|
|
|
187
237
|
若你通过 **Anthropic 兼容网关**(例如 DeepSeek 的 `/anthropic` 端点)调用模型,除了官方 Claude 以外,需要配置 **`ANTHROPIC_API_KEY`**(API 密钥)与 **`ANTHROPIC_BASE_URL`**(写入系统环境变量,见下表)。
|
|
@@ -237,7 +287,7 @@ CHATCCC_GIT_TIMEOUT_SECONDS=600
|
|
|
237
287
|
|
|
238
288
|
### 5. 开始使用
|
|
239
289
|
|
|
240
|
-
在飞书中找到你的机器人,发送 `/new`(默认 Claude Code)或 `/new claude` / `/new cursor`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
290
|
+
在飞书中找到你的机器人,发送 `/new`(默认 Claude Code)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
241
291
|
|
|
242
292
|
### 可用指令
|
|
243
293
|
|
|
@@ -247,10 +297,12 @@ CHATCCC_GIT_TIMEOUT_SECONDS=600
|
|
|
247
297
|
| `/new` | 创建新的 Claude Code 会话(默认) |
|
|
248
298
|
| `/new claude` | 创建新的 Claude Code 会话 |
|
|
249
299
|
| `/new cursor` | 创建新的 Cursor 会话 |
|
|
300
|
+
| `/new codex` | 创建新的 Codex 会话(OpenAI) |
|
|
250
301
|
| `/stop` | 停止当前正在生成的回复 |
|
|
251
302
|
| `/status` | 查看当前会话的状态(轮数、模型、上下文 token 等) |
|
|
252
303
|
| `/cd` | 查看/切换工作目录 |
|
|
253
304
|
| `/sessions` | 查看所有会话状态 |
|
|
305
|
+
| `/forget` | 重置当前会话(创建新 Session,保留工作目录,同一群内继续) |
|
|
254
306
|
| `/git <子命令>` | 在**当前会话工作目录**执行 `git ...` 并把 stdout/stderr 回发到群里(仅会话群内可用,超时见 `CHATCCC_GIT_TIMEOUT_SECONDS`) |
|
|
255
307
|
| `/restart` | 重启机器人进程 |
|
|
256
308
|
|
|
@@ -259,4 +311,4 @@ CHATCCC_GIT_TIMEOUT_SECONDS=600
|
|
|
259
311
|
|
|
260
312
|
## 技术栈
|
|
261
313
|
|
|
262
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / 飞书 WebSocket API / CardKit
|
|
314
|
+
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,12 +24,15 @@
|
|
|
24
24
|
"demo:create-group:local": "tsx --env-file=.env src/index.ts --local",
|
|
25
25
|
"demo:permission-check": "tsx --env-file=.env demo/permission_check.ts",
|
|
26
26
|
"demo:claude-hi": "tsx --env-file=.env demo/claude_say_hi.ts",
|
|
27
|
+
"demo:codex-hi": "tsx --env-file=.env demo/codex_say_hi.ts",
|
|
27
28
|
"test": "vitest run",
|
|
28
29
|
"test:watch": "vitest"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
32
33
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
34
|
+
"nodemailer": "^8.0.7",
|
|
35
|
+
"sharp": "^0.34.5",
|
|
33
36
|
"tsx": "^4.0.0",
|
|
34
37
|
"ws": "^8.18.0"
|
|
35
38
|
},
|
|
@@ -151,7 +151,7 @@ describe("buildHelpCard", () => {
|
|
|
151
151
|
const parsed = JSON.parse(card);
|
|
152
152
|
const action = parsed.elements[2];
|
|
153
153
|
expect(action.tag).toBe("action");
|
|
154
|
-
expect(action.actions).toHaveLength(
|
|
154
|
+
expect(action.actions).toHaveLength(5);
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
157
|
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
normalizeCodexMessage,
|
|
7
|
+
createCodexAdapter,
|
|
8
|
+
type CreateCodexAdapterOptions,
|
|
9
|
+
} from "../adapters/codex-adapter.ts";
|
|
10
|
+
import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
|
|
11
|
+
import {
|
|
12
|
+
type CodexSessionMeta,
|
|
13
|
+
type CodexSessionMetaStore,
|
|
14
|
+
} from "../adapters/codex-session-meta-store.ts";
|
|
15
|
+
import { accumulateBlockContent, pickFinalReply, type AccumulatorState } from "../session.ts";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
function readFixture(name: string): unknown[] {
|
|
20
|
+
const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
|
|
21
|
+
return raw
|
|
22
|
+
.split(/\r?\n/)
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((line) => JSON.parse(line));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 进程内内存版 meta store
|
|
28
|
+
function createInMemoryMetaStore(
|
|
29
|
+
initial: Record<string, CodexSessionMeta> = {},
|
|
30
|
+
): CodexSessionMetaStore & { snapshot(): Record<string, CodexSessionMeta> } {
|
|
31
|
+
const map = new Map<string, CodexSessionMeta>(Object.entries(initial));
|
|
32
|
+
return {
|
|
33
|
+
async get(sid) {
|
|
34
|
+
return map.get(sid);
|
|
35
|
+
},
|
|
36
|
+
async set(sid, partial) {
|
|
37
|
+
const existing = map.get(sid) ?? { cwd: "" };
|
|
38
|
+
const merged: CodexSessionMeta = { ...existing, ...partial };
|
|
39
|
+
if (typeof merged.cwd !== "string" || merged.cwd.length === 0) return;
|
|
40
|
+
map.set(sid, merged);
|
|
41
|
+
},
|
|
42
|
+
async setThreadId(sid, threadId) {
|
|
43
|
+
const existing = map.get(sid);
|
|
44
|
+
if (existing) {
|
|
45
|
+
map.set(sid, { ...existing, threadId });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
snapshot() {
|
|
49
|
+
return Object.fromEntries(map);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// normalizeCodexMessage — 核心映射逻辑测试(纯函数)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("normalizeCodexMessage", () => {
|
|
59
|
+
it("normalizes agent_message into assistant text block", () => {
|
|
60
|
+
const result = normalizeCodexMessage({
|
|
61
|
+
type: "item.completed",
|
|
62
|
+
item: { id: "item_0", type: "agent_message", text: "hello" },
|
|
63
|
+
});
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result!.type).toBe("assistant");
|
|
66
|
+
expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("normalizes command_execution start into tool_use block", () => {
|
|
70
|
+
const result = normalizeCodexMessage({
|
|
71
|
+
type: "item.started",
|
|
72
|
+
item: {
|
|
73
|
+
id: "item_0",
|
|
74
|
+
type: "command_execution",
|
|
75
|
+
command: "powershell.exe -Command ls",
|
|
76
|
+
status: "in_progress",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(result).not.toBeNull();
|
|
80
|
+
expect(result!.type).toBe("assistant");
|
|
81
|
+
expect(result!.blocks).toEqual([
|
|
82
|
+
{ type: "tool_use", name: "Bash", input: { command: "powershell.exe -Command ls" } },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("normalizes command_execution completion as tool_result (success)", () => {
|
|
87
|
+
const result = normalizeCodexMessage({
|
|
88
|
+
type: "item.completed",
|
|
89
|
+
item: {
|
|
90
|
+
id: "item_0",
|
|
91
|
+
type: "command_execution",
|
|
92
|
+
command: "ls",
|
|
93
|
+
aggregated_output: "file1\nfile2\n",
|
|
94
|
+
exit_code: 0,
|
|
95
|
+
status: "completed",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(result).not.toBeNull();
|
|
99
|
+
expect(result!.blocks).toEqual([
|
|
100
|
+
{
|
|
101
|
+
type: "tool_result",
|
|
102
|
+
tool_use_id: "item_0",
|
|
103
|
+
content: "file1\nfile2\n",
|
|
104
|
+
is_error: undefined,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("normalizes command_execution completion as tool_result (error)", () => {
|
|
110
|
+
const result = normalizeCodexMessage({
|
|
111
|
+
type: "item.completed",
|
|
112
|
+
item: {
|
|
113
|
+
id: "item_err",
|
|
114
|
+
type: "command_execution",
|
|
115
|
+
command: "nonexistent",
|
|
116
|
+
aggregated_output: "command not found",
|
|
117
|
+
exit_code: 127,
|
|
118
|
+
status: "completed",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(result).not.toBeNull();
|
|
122
|
+
expect(result!.blocks).toEqual([
|
|
123
|
+
{
|
|
124
|
+
type: "tool_result",
|
|
125
|
+
tool_use_id: "item_err",
|
|
126
|
+
content: "command not found",
|
|
127
|
+
is_error: true,
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns null for thread.started events", () => {
|
|
133
|
+
expect(
|
|
134
|
+
normalizeCodexMessage({
|
|
135
|
+
type: "thread.started",
|
|
136
|
+
thread_id: "abc-123",
|
|
137
|
+
}),
|
|
138
|
+
).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns null for turn.started events", () => {
|
|
142
|
+
expect(normalizeCodexMessage({ type: "turn.started" })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null for turn.completed events", () => {
|
|
146
|
+
expect(
|
|
147
|
+
normalizeCodexMessage({
|
|
148
|
+
type: "turn.completed",
|
|
149
|
+
usage: { input_tokens: 100, output_tokens: 50 },
|
|
150
|
+
}),
|
|
151
|
+
).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null for unknown event types", () => {
|
|
155
|
+
expect(normalizeCodexMessage({ type: "unknown" })).toBeNull();
|
|
156
|
+
expect(normalizeCodexMessage({} as Parameters<typeof normalizeCodexMessage>[0])).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns null for agent_message with empty text", () => {
|
|
160
|
+
const result = normalizeCodexMessage({
|
|
161
|
+
type: "item.completed",
|
|
162
|
+
item: { id: "item_0", type: "agent_message", text: "" },
|
|
163
|
+
});
|
|
164
|
+
expect(result).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns null for command_execution start without command text", () => {
|
|
168
|
+
const result = normalizeCodexMessage({
|
|
169
|
+
type: "item.started",
|
|
170
|
+
item: { id: "item_0", type: "command_execution", status: "in_progress" },
|
|
171
|
+
});
|
|
172
|
+
expect(result).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Fixture 端到端测试
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe("Codex stream fixtures", () => {
|
|
181
|
+
it("simple text: 流结束后 pickFinalReply 返回正确文本", () => {
|
|
182
|
+
const lines = readFixture("codex_simple_text.jsonl");
|
|
183
|
+
const state: AccumulatorState = {
|
|
184
|
+
accumulatedContent: "",
|
|
185
|
+
finalText: "",
|
|
186
|
+
finalCompleteText: "",
|
|
187
|
+
chunkCount: 0,
|
|
188
|
+
};
|
|
189
|
+
for (const raw of lines) {
|
|
190
|
+
const normalized = normalizeCodexMessage(
|
|
191
|
+
raw as Parameters<typeof normalizeCodexMessage>[0],
|
|
192
|
+
);
|
|
193
|
+
if (!normalized) continue;
|
|
194
|
+
for (const block of normalized.blocks) {
|
|
195
|
+
accumulateBlockContent(block, state);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
expect(pickFinalReply(state)).toBe("hello");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("with tool: 流结束后 pickFinalReply 返回最终文本(不含工具输出在 finalText 中)", () => {
|
|
203
|
+
const lines = readFixture("codex_with_tool.jsonl");
|
|
204
|
+
const state: AccumulatorState = {
|
|
205
|
+
accumulatedContent: "",
|
|
206
|
+
finalText: "",
|
|
207
|
+
finalCompleteText: "",
|
|
208
|
+
chunkCount: 0,
|
|
209
|
+
};
|
|
210
|
+
for (const raw of lines) {
|
|
211
|
+
const normalized = normalizeCodexMessage(
|
|
212
|
+
raw as Parameters<typeof normalizeCodexMessage>[0],
|
|
213
|
+
);
|
|
214
|
+
if (!normalized) continue;
|
|
215
|
+
for (const block of normalized.blocks) {
|
|
216
|
+
accumulateBlockContent(block, state);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// finalText 应该只包含最终的 agent_message
|
|
221
|
+
expect(pickFinalReply(state)).toBe("tool_test");
|
|
222
|
+
// accumulatedContent 包含工具调用信息
|
|
223
|
+
expect(state.accumulatedContent).toContain("Bash");
|
|
224
|
+
expect(state.accumulatedContent).toContain("tool_test");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("with tool: 只映射 agent_message 和 command_execution,不映射元事件", () => {
|
|
228
|
+
const lines = readFixture("codex_with_tool.jsonl");
|
|
229
|
+
const messages: UnifiedStreamMessage[] = [];
|
|
230
|
+
for (const raw of lines) {
|
|
231
|
+
const normalized = normalizeCodexMessage(
|
|
232
|
+
raw as Parameters<typeof normalizeCodexMessage>[0],
|
|
233
|
+
);
|
|
234
|
+
if (normalized) messages.push(normalized);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 应有: tool_use + tool_result + text = 3 条消息
|
|
238
|
+
expect(messages.length).toBe(3);
|
|
239
|
+
expect(messages[0].blocks[0].type).toBe("tool_use");
|
|
240
|
+
expect(messages[1].blocks[0].type).toBe("tool_result");
|
|
241
|
+
expect(messages[2].blocks[0].type).toBe("text");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// createCodexAdapter — 工厂函数测试
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
describe("createCodexAdapter", () => {
|
|
250
|
+
it("returns adapter with correct displayName and sessionDescPrefix", () => {
|
|
251
|
+
const adapter = createCodexAdapter();
|
|
252
|
+
expect(adapter.displayName).toBe("Codex");
|
|
253
|
+
expect(adapter.sessionDescPrefix).toBe("Codex Session:");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("closeSession does not throw", async () => {
|
|
257
|
+
const adapter = createCodexAdapter();
|
|
258
|
+
await expect(adapter.closeSession("any-id")).resolves.toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("getSessionInfo: store 中无该 sessionId 时返回 undefined", async () => {
|
|
262
|
+
const store = createInMemoryMetaStore();
|
|
263
|
+
const adapter = createCodexAdapter({ metaStore: store });
|
|
264
|
+
const info = await adapter.getSessionInfo("unknown-sid");
|
|
265
|
+
expect(info).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("getSessionInfo: store 有 cwd 时返回 cwd,无 threadId", async () => {
|
|
269
|
+
const store = createInMemoryMetaStore({
|
|
270
|
+
"sid-known": { cwd: "F:/proj/Foo" },
|
|
271
|
+
});
|
|
272
|
+
const adapter = createCodexAdapter({ metaStore: store });
|
|
273
|
+
const info = await adapter.getSessionInfo("sid-known");
|
|
274
|
+
expect(info).toEqual({ sessionId: "sid-known", cwd: "F:/proj/Foo" });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("getSessionInfo: 有 cwd + threadId 时一并返回", async () => {
|
|
278
|
+
const store = createInMemoryMetaStore({
|
|
279
|
+
"sid-known": { cwd: "F:/proj/Foo", threadId: "thread-123" },
|
|
280
|
+
});
|
|
281
|
+
const adapter = createCodexAdapter({ metaStore: store });
|
|
282
|
+
const info = await adapter.getSessionInfo("sid-known");
|
|
283
|
+
expect(info).toEqual({
|
|
284
|
+
sessionId: "sid-known",
|
|
285
|
+
cwd: "F:/proj/Foo",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("getSessionInfo: 不同 sessionId 互不影响", async () => {
|
|
290
|
+
const store = createInMemoryMetaStore({
|
|
291
|
+
"sid-A": { cwd: "/a", threadId: "tA" },
|
|
292
|
+
"sid-B": { cwd: "/b" },
|
|
293
|
+
});
|
|
294
|
+
const adapter = createCodexAdapter({ metaStore: store });
|
|
295
|
+
expect(await adapter.getSessionInfo("sid-A")).toEqual({
|
|
296
|
+
sessionId: "sid-A",
|
|
297
|
+
cwd: "/a",
|
|
298
|
+
});
|
|
299
|
+
expect(await adapter.getSessionInfo("sid-B")).toEqual({
|
|
300
|
+
sessionId: "sid-B",
|
|
301
|
+
cwd: "/b",
|
|
302
|
+
});
|
|
303
|
+
expect(await adapter.getSessionInfo("sid-C")).toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +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}}
|
|
@@ -0,0 +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}}
|