chatccc 0.2.214 → 0.2.216

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 CHANGED
@@ -295,10 +295,11 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
295
295
  },
296
296
  "codex": {
297
297
  "enabled": false,
298
- "defaultAgent": false,
299
- "path": "",
300
- "model": "",
301
- "effort": ""
298
+ "defaultAgent": false,
299
+ "path": "",
300
+ "model": "",
301
+ "effort": "",
302
+ "fastMode": false
302
303
  }
303
304
  }
304
305
  ```
@@ -319,7 +320,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
319
320
  | `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
320
321
  | `*.enabled` | 是否启用对应 AI Agent |
321
322
  | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具;飞书私聊会在下一条普通消息到达时跟随变化并创建新的空会话 |
322
- | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
323
+ | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
324
+ | `codex.fastMode` | Codex Fast 模式的全局默认值;默认 `false`,每次调用都会显式覆盖 Codex CLI 的 service tier |
323
325
  | `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
324
326
  | `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
325
327
  | `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
@@ -345,8 +347,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
345
347
  | `/new cursor` | 创建 Cursor 会话;飞书中会创建新群 |
346
348
  | `/new codex` | 创建 Codex 会话;飞书中会创建新群 |
347
349
  | `/newh` | 在当前聊天原地重置会话;群聊保留工作目录,飞书私聊固定使用系统用户目录 |
348
- | `/model` | 查看或切换当前会话的模型 |
349
- | `/stop` | 停止当前回复 |
350
+ | `/model` | 查看或切换当前会话的模型 |
351
+ | `/fast` | 查看当前 Codex 会话的 Fast 模式;使用 `/fast on` 或 `/fast off` 切换 |
352
+ | `/stop` | 停止当前回复 |
350
353
  | `/cancel` | 取消当前会话里排队等待处理的消息 |
351
354
  | `/state` | 查看当前会话状态 |
352
355
  | `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
@@ -364,6 +367,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
364
367
  `/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
365
368
 
366
369
  > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
370
+
371
+ > **Codex Fast 模式**:Web UI 中的“Fast 模式”设置新 Codex 会话的全局默认值,默认关闭。进入 Codex 会话后,`/fast` 查询当前状态,`/fast on` 和 `/fast off` 只覆盖当前会话并从下一条消息生效。ChatCCC 会显式向 Codex CLI 传入 `service_tier="fast"` 或 `service_tier="default"`,因此关闭时不会继承用户 `config.toml` 中可能开启的 Fast。
367
372
 
368
373
  ---
369
374
 
@@ -69,7 +69,8 @@
69
69
  "path": "",
70
70
  "model": "",
71
71
  "alternativeModel": "",
72
- "effort": ""
72
+ "effort": "",
73
+ "fastMode": false
73
74
  },
74
75
  "ccc": {
75
76
  "DEEPSEEK_API_KEY": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.214",
3
+ "version": "0.2.216",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -8,6 +8,8 @@ import {
8
8
  buildStatusCard,
9
9
  buildCodexUsageCard,
10
10
  buildCodexResetConfirmCard,
11
+ buildFastModeCard,
12
+ buildModelCard,
11
13
  buildButtons,
12
14
  truncateContent,
13
15
  getToolEmoji,
@@ -18,7 +20,7 @@ import { ABD_HELP_LINE } from "../shared-prefix.ts";
18
20
  // truncateContent
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
- describe("truncateContent", () => {
23
+ describe("truncateContent", () => {
22
24
  it("returns original text when under limits", () => {
23
25
  expect(truncateContent("hello")).toBe("hello");
24
26
  });
@@ -180,6 +182,40 @@ describe("buildHelpCard", () => {
180
182
  expect(text).not.toContain("CCC Agent");
181
183
  });
182
184
  });
185
+
186
+ describe("Codex Fast mode cards", () => {
187
+ it("shows the current mode and exposes ON/OFF commands", () => {
188
+ const card = JSON.parse(buildFastModeCard(false)) as {
189
+ header: { title: { content: string } };
190
+ elements: Array<{
191
+ tag: string;
192
+ text?: { content: string };
193
+ actions?: Array<{ text: { content: string }; value: string }>;
194
+ }>;
195
+ };
196
+
197
+ expect(card.header.title.content).toBe("Codex Fast 模式");
198
+ expect(card.elements.find((element) => element.tag === "div")?.text?.content).toContain("OFF");
199
+ const actions = card.elements.find((element) => element.tag === "action")?.actions ?? [];
200
+ expect(actions.map((action) => JSON.parse(action.value).cmd)).toEqual([
201
+ "/fast on",
202
+ "/fast off",
203
+ ]);
204
+ });
205
+
206
+ it("places /fast help immediately after /model help for Codex", () => {
207
+ const card = JSON.parse(buildModelCard("gpt-5", ["gpt-5"], "codex")) as {
208
+ elements: Array<{ tag: string; text?: { content: string } }>;
209
+ };
210
+ const content = card.elements.find((element) => element.tag === "div")?.text?.content ?? "";
211
+ const modelHelpIndex = content.indexOf("/model");
212
+ const fastHelpIndex = content.indexOf("/fast");
213
+
214
+ expect(modelHelpIndex).toBeGreaterThanOrEqual(0);
215
+ expect(fastHelpIndex).toBeGreaterThan(modelHelpIndex);
216
+ expect(content.slice(modelHelpIndex, fastHelpIndex).split("\n")).toHaveLength(2);
217
+ });
218
+ });
183
219
 
184
220
  // ---------------------------------------------------------------------------
185
221
  // buildCdContent
@@ -2,8 +2,9 @@ import { describe, it, expect } from "vitest";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import {
6
- normalizeCodexMessage,
5
+ import {
6
+ buildCodexInvocationArgs,
7
+ normalizeCodexMessage,
7
8
  createCodexAdapter,
8
9
  type CreateCodexAdapterOptions,
9
10
  } from "../adapters/codex-adapter.ts";
@@ -14,7 +15,21 @@ import {
14
15
  } from "../adapters/codex-session-meta-store.ts";
15
16
  import { accumulateBlockContent, pickFinalReply, type AccumulatorState } from "../session.ts";
16
17
 
17
- const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ describe("buildCodexInvocationArgs", () => {
21
+ it("forces the priority service tier when Fast mode is on", () => {
22
+ expect(buildCodexInvocationArgs(["exec", "--json"], "", "", true)).toContain(
23
+ 'service_tier="fast"',
24
+ );
25
+ });
26
+
27
+ it("forces the standard service tier when Fast mode is off", () => {
28
+ const args = buildCodexInvocationArgs(["exec", "--json"], "", "", false);
29
+ expect(args).toContain('service_tier="default"');
30
+ expect(args).not.toContain('service_tier="fast"');
31
+ });
32
+ });
18
33
 
19
34
  function readFixture(name: string): unknown[] {
20
35
  const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
@@ -71,7 +71,7 @@ const baseAppConfig: AppConfig = {
71
71
  avatarBatteryMode: "apiPercent",
72
72
  onDemandMonthlyBudget: 1000,
73
73
  },
74
- codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort" },
74
+ codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort", fastMode: false },
75
75
  ccc: { DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model" },
76
76
  };
77
77
 
@@ -207,15 +207,16 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
207
207
  applyLoadedConfig({
208
208
  ...structuredClone(baseAppConfig),
209
209
  feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
210
- codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", alternativeModel: "", effort: "low" },
210
+ codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", alternativeModel: "", effort: "low", fastMode: true },
211
211
  });
212
212
 
213
213
  // 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
214
214
  // 替换引用会破坏它们对 config.codex.* 的访问。
215
215
  expect(config).toBe(refBefore);
216
- expect(config.feishu.appId).toBe("REF_TEST_APP");
217
- expect(config.codex.path).toBe("/refresh/codex");
218
- });
216
+ expect(config.feishu.appId).toBe("REF_TEST_APP");
217
+ expect(config.codex.path).toBe("/refresh/codex");
218
+ expect(config.codex.fastMode).toBe(true);
219
+ });
219
220
 
220
221
  it("刷新 chromeDevtools 配置但保持 config 引用", () => {
221
222
  const refBefore = config;
@@ -28,15 +28,16 @@ describe("config.sample.json", () => {
28
28
  expect(sample.claude?.subagentModel).toBe("");
29
29
  });
30
30
 
31
- it("leaves Cursor and Codex alternative models empty by default", () => {
32
- const configSamplePath = join(process.cwd(), "config.sample.json");
33
- const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
34
- cursor?: { alternativeModel?: unknown };
35
- codex?: { alternativeModel?: unknown };
36
- };
37
-
31
+ it("leaves alternative models empty and Codex Fast mode off by default", () => {
32
+ const configSamplePath = join(process.cwd(), "config.sample.json");
33
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
34
+ cursor?: { alternativeModel?: unknown };
35
+ codex?: { alternativeModel?: unknown; fastMode?: unknown };
36
+ };
37
+
38
38
  expect(sample.cursor?.alternativeModel).toBe("");
39
39
  expect(sample.codex?.alternativeModel).toBe("");
40
+ expect(sample.codex?.fastMode).toBe(false);
40
41
  });
41
42
 
42
43
  it("sets ccc agent DeepSeek defaults in the sample config", () => {
@@ -0,0 +1,138 @@
1
+ import { mkdtemp, rm } 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
+ import {
8
+ FeishuMessageLedger,
9
+ createAckFirstEventHandler,
10
+ } from "../feishu-message-ingress.ts";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ async function createLedger(maxEntries = 5): Promise<FeishuMessageLedger> {
15
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-feishu-ingress-"));
16
+ tempDirs.push(dir);
17
+ return new FeishuMessageLedger(join(dir, "messages.json"), maxEntries);
18
+ }
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
22
+ });
23
+
24
+ describe("createAckFirstEventHandler", () => {
25
+ it("returns before starting the asynchronous event worker", async () => {
26
+ let scheduled: (() => void) | undefined;
27
+ const schedule = vi.fn((task: () => void) => {
28
+ scheduled = task;
29
+ });
30
+ const worker = vi.fn(async () => {});
31
+ const onError = vi.fn();
32
+ const handler = createAckFirstEventHandler(worker, onError, schedule);
33
+
34
+ await expect(handler({ message: "test" })).resolves.toBeUndefined();
35
+ expect(schedule).toHaveBeenCalledOnce();
36
+ expect(worker).not.toHaveBeenCalled();
37
+
38
+ scheduled?.();
39
+ await vi.waitFor(() => expect(worker).toHaveBeenCalledWith({ message: "test" }));
40
+ expect(onError).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("reports detached worker failures without rejecting the SDK callback", async () => {
44
+ const error = new Error("worker failed");
45
+ const onError = vi.fn();
46
+ const handler = createAckFirstEventHandler(
47
+ async () => {
48
+ throw error;
49
+ },
50
+ onError,
51
+ (task) => task(),
52
+ );
53
+
54
+ await expect(handler({})).resolves.toBeUndefined();
55
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
56
+ });
57
+ });
58
+
59
+ describe("FeishuMessageLedger", () => {
60
+ it("rejects an already accepted message after a process restart", async () => {
61
+ const first = await createLedger();
62
+ await first.load();
63
+
64
+ expect(await first.accept({
65
+ messageId: "om_001",
66
+ chatId: "oc_chat",
67
+ createTime: 100,
68
+ })).toBe("accepted");
69
+
70
+ const restarted = new FeishuMessageLedger(first.filePath, 5);
71
+ await restarted.load();
72
+
73
+ expect(await restarted.accept({
74
+ messageId: "om_001",
75
+ chatId: "oc_chat",
76
+ createTime: 100,
77
+ })).toBe("duplicate");
78
+ });
79
+
80
+ it("rejects a unique old event after restoring the chat high-water mark", async () => {
81
+ const first = await createLedger();
82
+ await first.load();
83
+
84
+ expect(await first.accept({
85
+ messageId: "om_new",
86
+ chatId: "oc_chat",
87
+ createTime: 200,
88
+ })).toBe("accepted");
89
+
90
+ const restarted = new FeishuMessageLedger(first.filePath, 5);
91
+ await restarted.load();
92
+
93
+ expect(await restarted.accept({
94
+ messageId: "om_old",
95
+ chatId: "oc_chat",
96
+ createTime: 100,
97
+ })).toBe("stale");
98
+ });
99
+
100
+ it("accepts distinct messages with the same create timestamp", async () => {
101
+ const ledger = await createLedger();
102
+ await ledger.load();
103
+
104
+ expect(await ledger.accept({
105
+ messageId: "om_001",
106
+ chatId: "oc_chat",
107
+ createTime: 100,
108
+ })).toBe("accepted");
109
+ expect(await ledger.accept({
110
+ messageId: "om_002",
111
+ chatId: "oc_chat",
112
+ createTime: 100,
113
+ })).toBe("accepted");
114
+ });
115
+
116
+ it("keeps only the configured number of recent message IDs", async () => {
117
+ const ledger = await createLedger(2);
118
+ await ledger.load();
119
+
120
+ await ledger.accept({ messageId: "om_001", chatId: "oc_chat", createTime: 100 });
121
+ await ledger.accept({ messageId: "om_002", chatId: "oc_chat", createTime: 200 });
122
+ await ledger.accept({ messageId: "om_003", chatId: "oc_chat", createTime: 300 });
123
+
124
+ const restarted = new FeishuMessageLedger(ledger.filePath, 2);
125
+ await restarted.load();
126
+
127
+ expect(await restarted.accept({
128
+ messageId: "om_001",
129
+ chatId: "oc_other",
130
+ createTime: 100,
131
+ })).toBe("accepted");
132
+ expect(await restarted.accept({
133
+ messageId: "om_003",
134
+ chatId: "oc_chat",
135
+ createTime: 300,
136
+ })).toBe("duplicate");
137
+ });
138
+ });
@@ -86,6 +86,7 @@ import {
86
86
  recordSessionRegistry,
87
87
  resetState,
88
88
  sessionInfoMap,
89
+ getEffectiveFastModeForTool,
89
90
  } from "../session.ts";
90
91
  import {
91
92
  activePrompts,
@@ -146,6 +147,7 @@ describe("handleCommand WeChat processing ack", () => {
146
147
  config.claude.defaultAgent = true;
147
148
  config.cursor.defaultAgent = false;
148
149
  config.codex.defaultAgent = false;
150
+ config.codex.fastMode = false;
149
151
  mockStreamStates.clear();
150
152
  mockGetCodexUsageSummary.mockReset();
151
153
  mockGetCursorUsageSummary.mockReset();
@@ -660,6 +662,55 @@ describe("handleCommand WeChat processing ack", () => {
660
662
  expect(registry["feishu-p2p"]?.sessionId).toBe("sid-feishu-private");
661
663
  });
662
664
 
665
+ it("queries and switches Fast mode for the current Codex session", async () => {
666
+ const platform = mockPlatform("feishu");
667
+ await recordSessionRegistry({
668
+ chatId: "feishu-codex",
669
+ sessionId: "sid-codex-fast",
670
+ tool: "codex",
671
+ chatType: "p2p",
672
+ chatName: "Codex",
673
+ running: false,
674
+ });
675
+
676
+ await handleCommand(platform, "/fast", "feishu-codex", "ou-user", Date.now(), "p2p");
677
+ let card = JSON.parse(
678
+ vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] ?? "{}",
679
+ ) as { elements?: Array<{ tag: string; text?: { content: string } }> };
680
+ expect(card.elements?.find((element) => element.tag === "div")?.text?.content).toContain("OFF");
681
+
682
+ await handleCommand(platform, "/fast on", "feishu-codex", "ou-user", Date.now(), "p2p");
683
+ expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(true);
684
+
685
+ await handleCommand(platform, "/fast off", "feishu-codex", "ou-user", Date.now(), "p2p");
686
+ expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(false);
687
+ card = JSON.parse(
688
+ vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] ?? "{}",
689
+ ) as { elements?: Array<{ tag: string; text?: { content: string } }> };
690
+ expect(card.elements?.find((element) => element.tag === "div")?.text?.content).toContain("OFF");
691
+ });
692
+
693
+ it("uses a text fallback for Fast mode commands on WeChat", async () => {
694
+ const platform = mockPlatform("wechat");
695
+ await recordSessionRegistry({
696
+ chatId: "wechat-codex",
697
+ sessionId: "sid-wechat-codex-fast",
698
+ tool: "codex",
699
+ chatType: "p2p",
700
+ chatName: "Codex",
701
+ running: false,
702
+ });
703
+
704
+ await handleCommand(platform, "/fast on", "wechat-codex", "wx-user", Date.now(), "p2p");
705
+
706
+ expect(getEffectiveFastModeForTool("codex", "sid-wechat-codex-fast")).toBe(true);
707
+ expect(platform.sendText).toHaveBeenCalledWith(
708
+ "wechat-codex",
709
+ expect.stringContaining("ON (Fast)"),
710
+ );
711
+ expect(platform.sendRawCard).not.toHaveBeenCalled();
712
+ });
713
+
663
714
  it("keeps the Feishu p2p session bound when /new creates a separate group", async () => {
664
715
  const platform = mockPlatform("feishu");
665
716
  const createSession = vi.fn(async () => ({ sessionId: "sid-feishu-group" }));
@@ -1023,7 +1074,15 @@ describe("handleCommand WeChat processing ack", () => {
1023
1074
  "green",
1024
1075
  );
1025
1076
 
1026
- const cursorPlatform = mockPlatform("feishu");
1077
+ const codexReadyCall = vi.mocked(codexPlatform.sendCard).mock.calls.find(
1078
+ ([chatId, title]) => chatId === "feishu-group" && title === "Codex Session Ready",
1079
+ );
1080
+ const modelHelpIndex = codexReadyCall?.[2].indexOf("/model") ?? -1;
1081
+ const fastHelpIndex = codexReadyCall?.[2].indexOf("/fast") ?? -1;
1082
+ expect(modelHelpIndex).toBeGreaterThanOrEqual(0);
1083
+ expect(fastHelpIndex).toBeGreaterThan(modelHelpIndex);
1084
+
1085
+ const cursorPlatform = mockPlatform("feishu");
1027
1086
  _setAdapterForToolForTest("cursor", mockAdapter("sid-cursor"));
1028
1087
 
1029
1088
  await handleCommand(cursorPlatform, "/new cursor", "feishu-p2p-cursor", "ou-user", Date.now(), "p2p");
@@ -126,9 +126,12 @@ import {
126
126
  _setFinalResponseCloseTimeoutForTest,
127
127
  _resetFinalResponseCloseTimeoutForTest,
128
128
  setSessionEffortOverride,
129
+ getEffectiveFastModeForTool,
130
+ setSessionFastModeOverride,
129
131
  RESPONSE_STALL_RECOVERY_PROMPT,
130
132
  RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
131
133
  } from "../session.ts";
134
+ import { config } from "../config.ts";
132
135
  import {
133
136
  activePrompts,
134
137
  bindChatToSession,
@@ -145,7 +148,26 @@ import {
145
148
  } from "../session-chat-binding.ts";
146
149
  import type { AccumulatorState } from "../session.ts";
147
150
  import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
148
- import type { PlatformAdapter } from "../platform-adapter.ts";
151
+ import type { PlatformAdapter } from "../platform-adapter.ts";
152
+
153
+ describe("Codex Fast mode overrides", () => {
154
+ it("uses the global default until the current session overrides it", () => {
155
+ const previousFastMode = config.codex.fastMode;
156
+ config.codex.fastMode = false;
157
+ resetState();
158
+
159
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
160
+ setSessionFastModeOverride("sid-fast", true);
161
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
162
+ setSessionFastModeOverride("sid-fast", false);
163
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
164
+ expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
165
+
166
+ resetState();
167
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
168
+ config.codex.fastMode = previousFastMode;
169
+ });
170
+ });
149
171
 
150
172
  // Helper to create a mock active session entry
151
173
  function mockActiveSession(chatId: string, overrides: Partial<{
@@ -73,9 +73,10 @@ describe("SimulatedPlatform", () => {
73
73
  });
74
74
 
75
75
  it("纯函数 formatDelayNotice 正常工作", () => {
76
- const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
77
- expect(notice).toBeDefined();
78
- expect(notice).toContain("延迟送达");
76
+ const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
77
+ expect(notice).toBeDefined();
78
+ expect(notice).toContain("延迟送达");
79
+ expect(notice).not.toContain("因服务离线");
79
80
  // 近期消息不触发
80
81
  expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
81
82
  });
@@ -68,11 +68,12 @@ describe("unflattenConfig", () => {
68
68
  });
69
69
  });
70
70
 
71
- it("maps Cursor and Codex alternative models into agent config", () => {
71
+ it("maps Cursor and Codex runtime settings into agent config", () => {
72
72
  expect(
73
73
  unflattenConfig({
74
74
  CHATCCC_CURSOR_ALTERNATIVE_MODEL: "gpt-5.5-high",
75
75
  CHATCCC_CODEX_ALTERNATIVE_MODEL: "gpt-5.3-codex",
76
+ CHATCCC_CODEX_FAST_MODE: true,
76
77
  }),
77
78
  ).toEqual({
78
79
  cursor: {
@@ -80,6 +81,7 @@ describe("unflattenConfig", () => {
80
81
  },
81
82
  codex: {
82
83
  alternativeModel: "gpt-5.3-codex",
84
+ fastMode: true,
83
85
  },
84
86
  });
85
87
  });
@@ -149,6 +151,7 @@ describe("getRestartRequiredReasons", () => {
149
151
  model: "",
150
152
  alternativeModel: "",
151
153
  effort: "",
154
+ fastMode: false,
152
155
  },
153
156
  };
154
157
 
@@ -159,7 +162,13 @@ describe("getRestartRequiredReasons", () => {
159
162
  chromeDevtools: { enabled: true, port: 15167, chromePath: "C:/Chrome/chrome.exe" },
160
163
  claude: { ...baseConfig.claude, model: "claude-sonnet", apiKey: "sk-test", maxTurn: 8 },
161
164
  cursor: { ...baseConfig.cursor, path: "C:/cursor-agent.cmd", model: "cursor-model" },
162
- codex: { ...baseConfig.codex, path: "C:/codex.cmd", model: "gpt-5.3-codex", effort: "high" },
165
+ codex: {
166
+ ...baseConfig.codex,
167
+ path: "C:/codex.cmd",
168
+ model: "gpt-5.3-codex",
169
+ effort: "high",
170
+ fastMode: true,
171
+ },
163
172
  }),
164
173
  ).toEqual([]);
165
174
  });
@@ -211,6 +220,12 @@ describe("dashboard edit modal", () => {
211
220
  expect(PAGE_HTML).toContain("生效范围:飞书开关、App ID、App Secret 或平台类型变更需要重启 ChatCCC");
212
221
  });
213
222
 
223
+ it("shows the Codex Fast mode switch in both the wizard and dashboard", () => {
224
+ expect(PAGE_HTML).toContain('id="field-CHATCCC_CODEX_FAST_MODE"');
225
+ expect(PAGE_HTML).toContain('id="cfg-CODEX_FAST_MODE"');
226
+ expect(PAGE_HTML).toContain("Fast 模式");
227
+ });
228
+
214
229
  it("shows the Web UI startup switch in both the wizard and dashboard", () => {
215
230
  expect(PAGE_HTML).toContain('id="field-CHATCCC_WEB_UI_OPEN_ON_START"');
216
231
  expect(PAGE_HTML).toContain('id="cfg-WEB_UI_OPEN_ON_START"');
@@ -199,13 +199,12 @@ export function normalizeCodexMessage(
199
199
  // 子进程辅助函数
200
200
  // ---------------------------------------------------------------------------
201
201
 
202
- function spawnCodex(
202
+ export function buildCodexInvocationArgs(
203
203
  args: string[],
204
- cwd?: string,
205
- stdinText?: string,
206
204
  modelOverride?: string,
207
205
  effortOverride?: string,
208
- ): ChildProcess {
206
+ fastModeOverride?: boolean,
207
+ ): string[] {
209
208
  const allArgs = [...args];
210
209
  const model = modelOverride ?? resolveCodexModel();
211
210
  if (model) {
@@ -217,6 +216,25 @@ function spawnCodex(
217
216
  if (effort) {
218
217
  allArgs.push("-c", `model_reasoning_effort="${effort}"`);
219
218
  }
219
+ const fastMode = fastModeOverride ?? config.codex.fastMode;
220
+ allArgs.push("-c", `service_tier="${fastMode ? "fast" : "default"}"`);
221
+ return allArgs;
222
+ }
223
+
224
+ function spawnCodex(
225
+ args: string[],
226
+ cwd?: string,
227
+ stdinText?: string,
228
+ modelOverride?: string,
229
+ effortOverride?: string,
230
+ fastModeOverride?: boolean,
231
+ ): ChildProcess {
232
+ const allArgs = buildCodexInvocationArgs(
233
+ args,
234
+ modelOverride,
235
+ effortOverride,
236
+ fastModeOverride,
237
+ );
220
238
 
221
239
  const proc = spawn(detectCodexCommand(), allArgs, {
222
240
  cwd,
@@ -269,11 +287,18 @@ class CodexAdapter implements ToolAdapter {
269
287
  private metaStore: CodexSessionMetaStore;
270
288
  private modelOverride: string | undefined;
271
289
  private effortOverride: string | undefined;
290
+ private fastModeOverride: boolean | undefined;
272
291
 
273
- constructor(metaStore: CodexSessionMetaStore, modelOverride?: string, effortOverride?: string) {
292
+ constructor(
293
+ metaStore: CodexSessionMetaStore,
294
+ modelOverride?: string,
295
+ effortOverride?: string,
296
+ fastModeOverride?: boolean,
297
+ ) {
274
298
  this.metaStore = metaStore;
275
299
  this.modelOverride = modelOverride;
276
300
  this.effortOverride = effortOverride;
301
+ this.fastModeOverride = fastModeOverride;
277
302
  }
278
303
 
279
304
  // createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
@@ -304,7 +329,14 @@ class CodexAdapter implements ToolAdapter {
304
329
  ? [...baseArgs, "-C", cwd, "-"]
305
330
  : [...baseArgs, "resume", threadId, "-"];
306
331
 
307
- const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride);
332
+ const proc = spawnCodex(
333
+ args,
334
+ cwd,
335
+ buildCodexPromptText(userText),
336
+ this.modelOverride,
337
+ this.effortOverride,
338
+ this.fastModeOverride,
339
+ );
308
340
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
309
341
 
310
342
  const rawLogConfig = config.rawStreamLogs.codex;
@@ -379,6 +411,7 @@ export interface CreateCodexAdapterOptions {
379
411
  /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
380
412
  model?: string;
381
413
  effort?: string;
414
+ fastMode?: boolean;
382
415
  }
383
416
 
384
417
  export function createCodexAdapter(
@@ -388,5 +421,6 @@ export function createCodexAdapter(
388
421
  options.metaStore ?? defaultCodexSessionMetaStore,
389
422
  options.model,
390
423
  options.effort,
424
+ options.fastMode,
391
425
  );
392
426
  }