chatccc 0.2.178 → 0.2.179

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
@@ -308,13 +308,13 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
308
308
  | `/stop` | 停止当前回复 |
309
309
  | `/state` | 查看当前会话状态 |
310
310
  | `/cd` | 查看或设置当前会话工作目录 |
311
- | `/sessions` | 查看所有会话状态 |
312
- | `/usage` | 查看 Codex 5h 用量和周用量 |
313
- | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
311
+ | `/sessions` | 查看所有会话状态 |
312
+ | `/usage` | 查看 Codex 5h 用量和周用量 |
313
+ | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
314
314
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
315
315
  | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
316
316
  | `/restart` | 重启机器人进程 |
317
- | `/updateg` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
317
+ | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
318
318
 
319
319
  > **模型切换**:`/model` 查看可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。当前仅 Claude Code 支持模型切换(需同时填写 `claude.model` 和 `claude.subagentModel`),Cursor / Codex 切换正在开发中。
320
320
 
@@ -24,7 +24,9 @@
24
24
  "enabled": false,
25
25
  "defaultAgent": false,
26
26
  "path": "",
27
- "model": ""
27
+ "model": "",
28
+ "avatarBatteryMode": "apiPercent",
29
+ "onDemandMonthlyBudget": 1000
28
30
  },
29
31
  "codex": {
30
32
  "enabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.178",
3
+ "version": "0.2.179",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -17,7 +17,7 @@ describe("cardJsonToPlainText", () => {
17
17
  expect(text).toContain("/new cursor");
18
18
  expect(text).toContain("/new codex");
19
19
  expect(text).toContain("/restart");
20
- expect(text).toContain("/updateg");
20
+ expect(text).toContain("/update");
21
21
  expect(text).toContain("/cd");
22
22
  });
23
23
 
@@ -8,11 +8,11 @@ import {
8
8
  APP_ID,
9
9
  APP_SECRET,
10
10
  CHATCCC_PORT,
11
- CLAUDE_API_KEY,
12
- CLAUDE_BASE_URL,
13
- CLAUDE_EFFORT,
14
- CLAUDE_MAX_TURN,
15
- CLAUDE_MODEL,
11
+ CLAUDE_API_KEY,
12
+ CLAUDE_BASE_URL,
13
+ CLAUDE_EFFORT,
14
+ CLAUDE_MAX_TURN,
15
+ CLAUDE_MODEL,
16
16
  CLAUDE_SUBAGENT_MODEL,
17
17
  CURSOR_AGENT_ARGS,
18
18
  CURSOR_AGENT_COMMAND,
@@ -53,7 +53,14 @@ const baseAppConfig: AppConfig = {
53
53
  baseUrl: "",
54
54
  maxTurn: 0,
55
55
  },
56
- cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
56
+ cursor: {
57
+ enabled: true,
58
+ defaultAgent: false,
59
+ path: "/initial/cursor",
60
+ model: "initial-cursor-model",
61
+ avatarBatteryMode: "apiPercent",
62
+ onDemandMonthlyBudget: 1000,
63
+ },
57
64
  codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
58
65
  };
59
66
 
@@ -82,7 +89,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
82
89
  expect(APP_SECRET).toBe("NEW_APP_SECRET");
83
90
  });
84
91
 
85
- it("更新 Claude 配置(model / subagentModel / effort / maxTurn / apiKey / baseUrl)", () => {
92
+ it("更新 Claude 配置(model / subagentModel / effort / maxTurn / apiKey / baseUrl)", () => {
86
93
  applyLoadedConfig({
87
94
  ...structuredClone(baseAppConfig),
88
95
  claude: {
@@ -90,18 +97,18 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
90
97
  defaultAgent: true,
91
98
  model: "claude-sonnet-4-6",
92
99
  subagentModel: "claude-haiku-4-5-20251001",
93
- effort: "high",
94
- apiKey: "sk-test",
95
- baseUrl: "https://api.example.com",
96
- maxTurn: 7,
97
- },
98
- });
99
-
100
- expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
101
- expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
102
- expect(CLAUDE_EFFORT).toBe("high");
103
- expect(CLAUDE_MAX_TURN).toBe(7);
104
- expect(CLAUDE_API_KEY).toBe("sk-test");
100
+ effort: "high",
101
+ apiKey: "sk-test",
102
+ baseUrl: "https://api.example.com",
103
+ maxTurn: 7,
104
+ },
105
+ });
106
+
107
+ expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
108
+ expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
109
+ expect(CLAUDE_EFFORT).toBe("high");
110
+ expect(CLAUDE_MAX_TURN).toBe(7);
111
+ expect(CLAUDE_API_KEY).toBe("sk-test");
105
112
  expect(CLAUDE_BASE_URL).toBe("https://api.example.com");
106
113
  });
107
114
 
@@ -132,7 +139,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
132
139
  it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
133
140
  applyLoadedConfig({
134
141
  ...structuredClone(baseAppConfig),
135
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
142
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet", avatarBatteryMode: "apiPercent", onDemandMonthlyBudget: 1000 },
136
143
  });
137
144
 
138
145
  // CURSOR_AGENT_ARGS 是 ['-p', '--force', '--approve-mcps', ..., '--model', 'claude-3.7-sonnet']
@@ -144,7 +151,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
144
151
  it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
145
152
  applyLoadedConfig({
146
153
  ...structuredClone(baseAppConfig),
147
- cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
154
+ cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "", avatarBatteryMode: "apiPercent", onDemandMonthlyBudget: 1000 },
148
155
  });
149
156
 
150
157
  expect(CURSOR_AGENT_ARGS).not.toContain("--model");
@@ -153,7 +160,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
153
160
  it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
154
161
  applyLoadedConfig({
155
162
  ...structuredClone(baseAppConfig),
156
- cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
163
+ cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "", avatarBatteryMode: "apiPercent", onDemandMonthlyBudget: 1000 },
157
164
  });
158
165
 
159
166
  expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
@@ -1,129 +1,219 @@
1
- import { mkdir, mkdtemp, rm, writeFile } 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
- async function loadFeishuApiWithHome(homeDir: string, userDataDir: string) {
8
- vi.resetModules();
9
- vi.doMock("node:os", () => ({ homedir: () => homeDir }));
10
- vi.doMock("../config.ts", () => ({
11
- APP_ID: "app_id",
12
- APP_SECRET: "app_secret",
13
- BASE_URL: "https://open.feishu.test",
14
- CHAT_LOGS_DIR: join(userDataDir, "logs"),
15
- PROJECT_ROOT: process.cwd(),
16
- USER_DATA_DIR: userDataDir,
17
- CLAUDE_SESSION_PREFIX: "Claude Code Session:",
18
- CURSOR_SESSION_PREFIX: "Cursor Session:",
19
- CODEX_SESSION_PREFIX: "Codex Session:",
20
- ts: () => "test-ts",
21
- resolveDefaultAgentTool: () => "claude",
22
- toolDisplayName: (tool: string) => tool,
23
- }));
24
- return import("../feishu-api.ts");
25
- }
26
-
27
- async function writeCodexAuth(homeDir: string): Promise<void> {
28
- const codexDir = join(homeDir, ".codex");
29
- await mkdir(codexDir, { recursive: true });
30
- await writeFile(
31
- join(codexDir, "auth.json"),
32
- JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
33
- "utf-8",
34
- );
35
- }
36
-
37
- function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void {
38
- vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
39
- const urlText = String(url);
40
- if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
41
- return usageResponse.clone();
42
- }
43
- if (urlText === "https://open.feishu.test/im/v1/images") {
44
- const form = init?.body as FormData;
45
- const image = form.get("image") as File;
46
- uploadedNames.push(image.name);
47
- return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
48
- }
49
- if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
50
- return new Response(JSON.stringify({ code: 0 }), { status: 200 });
51
- }
52
- throw new Error(`unexpected fetch: ${urlText}`);
53
- }));
54
- }
55
-
56
- describe("Codex avatar usage battery", () => {
57
- afterEach(() => {
58
- vi.unstubAllGlobals();
59
- vi.doUnmock("node:os");
60
- vi.doUnmock("../config.ts");
61
- vi.restoreAllMocks();
62
- });
63
-
64
- it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
65
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
66
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
67
- const uploadedNames: string[] = [];
68
- await writeCodexAuth(homeDir);
69
- mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
70
- rate_limit: {
71
- primary_window: { used_percent: 37 },
72
- secondary_window: { used_percent: 12 },
73
- },
74
- }), { status: 200 }));
75
-
76
- try {
77
- const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
78
- await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
79
-
80
- expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
81
- } finally {
82
- await rm(homeDir, { recursive: true, force: true });
83
- await rm(userDataDir, { recursive: true, force: true });
84
- }
85
- });
86
-
87
- it("returns both 5h and weekly Codex usage windows", async () => {
88
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
89
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
90
- const uploadedNames: string[] = [];
91
- await writeCodexAuth(homeDir);
92
- mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
93
- rate_limit: {
94
- primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
95
- secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
96
- },
97
- }), { status: 200 }));
98
-
99
- try {
100
- const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
101
- await expect(getCodexUsageSummary()).resolves.toEqual({
102
- fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
103
- weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
104
- });
105
- } finally {
106
- await rm(homeDir, { recursive: true, force: true });
107
- await rm(userDataDir, { recursive: true, force: true });
108
- }
109
- });
110
-
111
- it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
112
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
113
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
114
- const uploadedNames: string[] = [];
115
- await writeCodexAuth(homeDir);
116
- vi.spyOn(console, "warn").mockImplementation(() => {});
117
- mockAvatarFetch(uploadedNames, new Response("fail", { status: 500 }));
118
-
119
- try {
120
- const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
121
- await setChatAvatar("tenant-token", "chat_1", "codex", "idle");
122
-
123
- expect(uploadedNames).toEqual(["avatar_codex_idle.jpg"]);
124
- } finally {
125
- await rm(homeDir, { recursive: true, force: true });
126
- await rm(userDataDir, { recursive: true, force: true });
127
- }
128
- });
129
- });
1
+ import { mkdir, mkdtemp, rm, writeFile } 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 mockConfig = {
8
+ cursor: {
9
+ avatarBatteryMode: "apiPercent",
10
+ onDemandMonthlyBudget: 1000,
11
+ },
12
+ };
13
+ const getCursorUsageSummaryMock = vi.fn();
14
+
15
+ async function loadFeishuApiWithHome(homeDir: string, userDataDir: string) {
16
+ vi.resetModules();
17
+ vi.doMock("node:os", () => ({ homedir: () => homeDir }));
18
+ vi.doMock("../config.ts", () => ({
19
+ APP_ID: "app_id",
20
+ APP_SECRET: "app_secret",
21
+ BASE_URL: "https://open.feishu.test",
22
+ CHAT_LOGS_DIR: join(userDataDir, "logs"),
23
+ PROJECT_ROOT: process.cwd(),
24
+ USER_DATA_DIR: userDataDir,
25
+ CLAUDE_SESSION_PREFIX: "Claude Code Session:",
26
+ CURSOR_SESSION_PREFIX: "Cursor Session:",
27
+ CODEX_SESSION_PREFIX: "Codex Session:",
28
+ ts: () => "test-ts",
29
+ resolveDefaultAgentTool: () => "claude",
30
+ toolDisplayName: (tool: string) => tool,
31
+ config: mockConfig,
32
+ }));
33
+ vi.doMock("../cursor-usage.ts", () => ({
34
+ getCursorUsageSummary: getCursorUsageSummaryMock,
35
+ }));
36
+ return import("../feishu-api.ts");
37
+ }
38
+
39
+ async function writeCodexAuth(homeDir: string): Promise<void> {
40
+ const codexDir = join(homeDir, ".codex");
41
+ await mkdir(codexDir, { recursive: true });
42
+ await writeFile(
43
+ join(codexDir, "auth.json"),
44
+ JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
45
+ "utf-8",
46
+ );
47
+ }
48
+
49
+ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void {
50
+ vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
51
+ const urlText = String(url);
52
+ if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
53
+ return usageResponse.clone();
54
+ }
55
+ if (urlText === "https://open.feishu.test/im/v1/images") {
56
+ const form = init?.body as FormData;
57
+ const image = form.get("image") as File;
58
+ uploadedNames.push(image.name);
59
+ return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
60
+ }
61
+ if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
62
+ return new Response(JSON.stringify({ code: 0 }), { status: 200 });
63
+ }
64
+ throw new Error(`unexpected fetch: ${urlText}`);
65
+ }));
66
+ }
67
+
68
+ describe("Codex avatar usage battery", () => {
69
+ afterEach(() => {
70
+ vi.unstubAllGlobals();
71
+ vi.doUnmock("node:os");
72
+ vi.doUnmock("../config.ts");
73
+ vi.doUnmock("../cursor-usage.ts");
74
+ vi.restoreAllMocks();
75
+ getCursorUsageSummaryMock.mockReset();
76
+ mockConfig.cursor.avatarBatteryMode = "apiPercent";
77
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
78
+ });
79
+
80
+ it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
81
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
82
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
83
+ const uploadedNames: string[] = [];
84
+ await writeCodexAuth(homeDir);
85
+ mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
86
+ rate_limit: {
87
+ primary_window: { used_percent: 37 },
88
+ secondary_window: { used_percent: 12 },
89
+ },
90
+ }), { status: 200 }));
91
+
92
+ try {
93
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
94
+ await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
95
+
96
+ expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
97
+ } finally {
98
+ await rm(homeDir, { recursive: true, force: true });
99
+ await rm(userDataDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it("returns both 5h and weekly Codex usage windows", async () => {
104
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
105
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
106
+ const uploadedNames: string[] = [];
107
+ await writeCodexAuth(homeDir);
108
+ mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
109
+ rate_limit: {
110
+ primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
111
+ secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
112
+ },
113
+ }), { status: 200 }));
114
+
115
+ try {
116
+ const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
117
+ await expect(getCodexUsageSummary()).resolves.toEqual({
118
+ fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
119
+ weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
120
+ });
121
+ } finally {
122
+ await rm(homeDir, { recursive: true, force: true });
123
+ await rm(userDataDir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
128
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
129
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
130
+ const uploadedNames: string[] = [];
131
+ await writeCodexAuth(homeDir);
132
+ vi.spyOn(console, "warn").mockImplementation(() => {});
133
+ mockAvatarFetch(uploadedNames, new Response("fail", { status: 500 }));
134
+
135
+ try {
136
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
137
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle");
138
+
139
+ expect(uploadedNames).toEqual(["avatar_codex_idle.jpg"]);
140
+ } finally {
141
+ await rm(homeDir, { recursive: true, force: true });
142
+ await rm(userDataDir, { recursive: true, force: true });
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("Cursor avatar usage battery", () => {
148
+ afterEach(() => {
149
+ vi.unstubAllGlobals();
150
+ vi.doUnmock("node:os");
151
+ vi.doUnmock("../config.ts");
152
+ vi.doUnmock("../cursor-usage.ts");
153
+ vi.restoreAllMocks();
154
+ getCursorUsageSummaryMock.mockReset();
155
+ mockConfig.cursor.avatarBatteryMode = "apiPercent";
156
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
157
+ });
158
+
159
+ it("uses remaining API percentage for Cursor avatar battery", async () => {
160
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
161
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
162
+ const uploadedNames: string[] = [];
163
+ getCursorUsageSummaryMock.mockResolvedValue({
164
+ planUsage: { apiPercentUsed: 40 },
165
+ });
166
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
167
+
168
+ try {
169
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
170
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "busy");
171
+
172
+ expect(uploadedNames).toEqual(["avatar_cursor_busy_battery_60.jpg"]);
173
+ } finally {
174
+ await rm(homeDir, { recursive: true, force: true });
175
+ await rm(userDataDir, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ it("uses On-Demand budget for Cursor avatar battery when configured", async () => {
180
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
181
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
182
+ const uploadedNames: string[] = [];
183
+ mockConfig.cursor.avatarBatteryMode = "onDemandUse";
184
+ mockConfig.cursor.onDemandMonthlyBudget = 1000;
185
+ getCursorUsageSummaryMock.mockResolvedValue({
186
+ spendLimitUsage: { individualUsed: 25000 },
187
+ });
188
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
189
+
190
+ try {
191
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
192
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "idle");
193
+
194
+ expect(uploadedNames).toEqual(["avatar_cursor_idle_battery_75.jpg"]);
195
+ } finally {
196
+ await rm(homeDir, { recursive: true, force: true });
197
+ await rm(userDataDir, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ it("falls back to the pre-combined Cursor avatar when usage lookup fails", async () => {
202
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
203
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
204
+ const uploadedNames: string[] = [];
205
+ vi.spyOn(console, "warn").mockImplementation(() => {});
206
+ getCursorUsageSummaryMock.mockRejectedValue(new Error("cursor unavailable"));
207
+ mockAvatarFetch(uploadedNames, new Response("unused", { status: 500 }));
208
+
209
+ try {
210
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
211
+ await setChatAvatar("tenant-token", "chat_1", "cursor", "busy");
212
+
213
+ expect(uploadedNames).toEqual(["avatar_cursor_busy.jpg"]);
214
+ } finally {
215
+ await rm(homeDir, { recursive: true, force: true });
216
+ await rm(userDataDir, { recursive: true, force: true });
217
+ }
218
+ });
219
+ });