chatccc 0.2.198 → 0.2.200

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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/agent-prompts/claude_specific.md +45 -45
  3. package/agent-prompts/codex_specific.md +2 -2
  4. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  5. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  6. package/im-skills/feishu-skill/skill.md +3 -3
  7. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  8. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  9. package/im-skills/wechat-file-skill/skill.md +10 -10
  10. package/im-skills/wechat-image-skill/skill.md +10 -10
  11. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  12. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  13. package/im-skills/wechat-video-skill/skill.md +10 -10
  14. package/package.json +1 -1
  15. package/scripts/postinstall-sharp-check.mjs +58 -58
  16. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  17. package/src/__tests__/card-plain-text.test.ts +5 -5
  18. package/src/__tests__/cardkit.test.ts +60 -60
  19. package/src/__tests__/cards.test.ts +1 -1
  20. package/src/__tests__/claude-adapter.test.ts +592 -592
  21. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  22. package/src/__tests__/cursor-adapter.test.ts +66 -7
  23. package/src/__tests__/feishu-api.test.ts +60 -60
  24. package/src/__tests__/feishu-avatar.test.ts +47 -5
  25. package/src/__tests__/feishu-platform.test.ts +22 -22
  26. package/src/__tests__/format-message.test.ts +47 -47
  27. package/src/__tests__/orchestrator.test.ts +27 -2
  28. package/src/__tests__/privacy.test.ts +198 -198
  29. package/src/__tests__/shared-prefix.test.ts +36 -36
  30. package/src/__tests__/stream-state.test.ts +42 -42
  31. package/src/adapters/claude-session-meta-store.ts +120 -120
  32. package/src/adapters/cursor-adapter.ts +39 -6
  33. package/src/adapters/resource-monitor.ts +140 -140
  34. package/src/agent-delegate-task-rpc.ts +153 -153
  35. package/src/agent-delegate-task.ts +81 -81
  36. package/src/agent-stop-stuck.ts +129 -129
  37. package/src/cards.ts +1 -1
  38. package/src/codex-reset-actions.ts +184 -184
  39. package/src/feishu-api.ts +41 -18
  40. package/src/feishu-platform.ts +20 -20
  41. package/src/format-message.ts +293 -293
  42. package/src/litellm-proxy.ts +374 -374
  43. package/src/orchestrator.ts +4 -4
  44. package/src/privacy.ts +118 -118
  45. package/src/session-chat-binding.ts +6 -6
  46. package/src/session-name.ts +8 -8
  47. package/src/shared-prefix.ts +29 -29
  48. package/src/sim-platform.ts +20 -20
  49. package/src/turn-cards.ts +117 -117
@@ -1,146 +1,146 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- import {
4
- _resetCodexResetRequestsForTest,
5
- handleCodexResetCardAction,
6
- type CodexResetActionDeps,
7
- } from "../codex-reset-actions.ts";
8
-
9
- function deps(): CodexResetActionDeps & {
10
- getTenantAccessToken: ReturnType<typeof vi.fn>;
11
- sendRawCard: ReturnType<typeof vi.fn>;
12
- sendTextReply: ReturnType<typeof vi.fn>;
13
- sendCardReply: ReturnType<typeof vi.fn>;
14
- updateCardMessage: ReturnType<typeof vi.fn>;
15
- recallMessage: ReturnType<typeof vi.fn>;
16
- consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>;
17
- createRequestId: ReturnType<typeof vi.fn>;
18
- } {
19
- return {
20
- getTenantAccessToken: vi.fn(async () => "tenant-token"),
21
- sendRawCard: vi.fn(async () => true),
22
- sendTextReply: vi.fn(async () => true),
23
- sendCardReply: vi.fn(async () => true),
24
- updateCardMessage: vi.fn(async () => true),
25
- recallMessage: vi.fn(async () => true),
26
- consumeCodexRateLimitResetCredit: vi.fn(async () => ({ code: "reset" as const, windowsReset: 2 })),
27
- createRequestId: vi.fn(() => "request-1"),
28
- };
29
- }
30
-
31
- function event(value: Record<string, unknown>, messageId: string) {
32
- return {
33
- event: {
34
- open_message_id: messageId,
35
- open_chat_id: "chat-1",
36
- operator: { open_id: "ou-user" },
37
- action: { value },
38
- },
39
- };
40
- }
41
-
42
- describe("Codex reset card actions", () => {
43
- beforeEach(() => {
44
- _resetCodexResetRequestsForTest();
45
- });
46
-
47
- it("sends a confirmation card tied to the clicked usage card", async () => {
48
- const d = deps();
49
-
50
- await expect(handleCodexResetCardAction(
51
- event({ action: "codex_reset_request" }, "usage-message"),
52
- d,
53
- )).resolves.toBe(true);
54
-
55
- expect(d.sendRawCard).toHaveBeenCalledTimes(1);
56
- expect(d.sendRawCard.mock.calls[0][1]).toBe("chat-1");
57
- const confirmCard = JSON.parse(d.sendRawCard.mock.calls[0][2]);
58
- const action = confirmCard.elements.find((element: any) => element.tag === "action");
59
- expect(action.actions[0].value).toEqual({
60
- action: "codex_reset_confirm",
61
- decision: "yes",
62
- parentMessageId: "usage-message",
63
- requestId: "request-1",
64
- });
65
- });
66
-
67
- it("consumes a reset credit, sends the result, and collapses the confirmation card when user confirms", async () => {
68
- const d = deps();
69
- await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
70
-
71
- await expect(handleCodexResetCardAction(
72
- event({
73
- action: "codex_reset_confirm",
74
- decision: "yes",
75
- parentMessageId: "usage-message",
76
- requestId: "request-1",
77
- }, "confirm-message"),
78
- d,
79
- )).resolves.toBe(true);
80
-
81
- expect(d.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith("request-1");
82
- expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
83
- expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message");
84
- expect(JSON.parse(d.updateCardMessage.mock.calls[0][2]).elements).toEqual([
85
- { tag: "markdown", content: " " },
86
- ]);
87
- expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
88
- expect(d.sendTextReply).toHaveBeenCalledWith(
89
- "tenant-token",
90
- "chat-1",
91
- expect.stringContaining("重置成功"),
92
- );
93
- expect(d.sendCardReply).not.toHaveBeenCalled();
94
- });
95
-
96
- it("does not consume a reset credit and sends a cancellation message when user declines", async () => {
97
- const d = deps();
98
- await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
99
-
100
- await expect(handleCodexResetCardAction(
101
- event({
102
- action: "codex_reset_confirm",
103
- decision: "no",
104
- parentMessageId: "usage-message",
105
- requestId: "request-1",
106
- }, "confirm-message"),
107
- d,
108
- )).resolves.toBe(true);
109
-
110
- expect(d.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled();
111
- expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
112
- expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
113
- expect(d.sendTextReply).toHaveBeenCalledWith(
114
- "tenant-token",
115
- "chat-1",
116
- "Codex 主动重置:用户取消了重置。",
117
- );
118
- expect(d.sendCardReply).not.toHaveBeenCalled();
119
- });
120
-
121
- it("can collapse a confirmation card using context.message_id fallback", async () => {
122
- const d = deps();
123
- await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
124
-
125
- await expect(handleCodexResetCardAction({
126
- event: {
127
- context: {
128
- message_id: "confirm-message-from-context",
129
- open_chat_id: "chat-1",
130
- },
131
- action: {
132
- value: {
133
- action: "codex_reset_confirm",
134
- decision: "no",
135
- parentMessageId: "usage-message",
136
- requestId: "request-1",
137
- },
138
- },
139
- },
140
- }, d)).resolves.toBe(true);
141
-
142
- expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
143
- expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message-from-context");
144
- expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message-from-context");
145
- });
146
- });
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ _resetCodexResetRequestsForTest,
5
+ handleCodexResetCardAction,
6
+ type CodexResetActionDeps,
7
+ } from "../codex-reset-actions.ts";
8
+
9
+ function deps(): CodexResetActionDeps & {
10
+ getTenantAccessToken: ReturnType<typeof vi.fn>;
11
+ sendRawCard: ReturnType<typeof vi.fn>;
12
+ sendTextReply: ReturnType<typeof vi.fn>;
13
+ sendCardReply: ReturnType<typeof vi.fn>;
14
+ updateCardMessage: ReturnType<typeof vi.fn>;
15
+ recallMessage: ReturnType<typeof vi.fn>;
16
+ consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>;
17
+ createRequestId: ReturnType<typeof vi.fn>;
18
+ } {
19
+ return {
20
+ getTenantAccessToken: vi.fn(async () => "tenant-token"),
21
+ sendRawCard: vi.fn(async () => true),
22
+ sendTextReply: vi.fn(async () => true),
23
+ sendCardReply: vi.fn(async () => true),
24
+ updateCardMessage: vi.fn(async () => true),
25
+ recallMessage: vi.fn(async () => true),
26
+ consumeCodexRateLimitResetCredit: vi.fn(async () => ({ code: "reset" as const, windowsReset: 2 })),
27
+ createRequestId: vi.fn(() => "request-1"),
28
+ };
29
+ }
30
+
31
+ function event(value: Record<string, unknown>, messageId: string) {
32
+ return {
33
+ event: {
34
+ open_message_id: messageId,
35
+ open_chat_id: "chat-1",
36
+ operator: { open_id: "ou-user" },
37
+ action: { value },
38
+ },
39
+ };
40
+ }
41
+
42
+ describe("Codex reset card actions", () => {
43
+ beforeEach(() => {
44
+ _resetCodexResetRequestsForTest();
45
+ });
46
+
47
+ it("sends a confirmation card tied to the clicked usage card", async () => {
48
+ const d = deps();
49
+
50
+ await expect(handleCodexResetCardAction(
51
+ event({ action: "codex_reset_request" }, "usage-message"),
52
+ d,
53
+ )).resolves.toBe(true);
54
+
55
+ expect(d.sendRawCard).toHaveBeenCalledTimes(1);
56
+ expect(d.sendRawCard.mock.calls[0][1]).toBe("chat-1");
57
+ const confirmCard = JSON.parse(d.sendRawCard.mock.calls[0][2]);
58
+ const action = confirmCard.elements.find((element: any) => element.tag === "action");
59
+ expect(action.actions[0].value).toEqual({
60
+ action: "codex_reset_confirm",
61
+ decision: "yes",
62
+ parentMessageId: "usage-message",
63
+ requestId: "request-1",
64
+ });
65
+ });
66
+
67
+ it("consumes a reset credit, sends the result, and collapses the confirmation card when user confirms", async () => {
68
+ const d = deps();
69
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
70
+
71
+ await expect(handleCodexResetCardAction(
72
+ event({
73
+ action: "codex_reset_confirm",
74
+ decision: "yes",
75
+ parentMessageId: "usage-message",
76
+ requestId: "request-1",
77
+ }, "confirm-message"),
78
+ d,
79
+ )).resolves.toBe(true);
80
+
81
+ expect(d.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith("request-1");
82
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
83
+ expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message");
84
+ expect(JSON.parse(d.updateCardMessage.mock.calls[0][2]).elements).toEqual([
85
+ { tag: "markdown", content: " " },
86
+ ]);
87
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
88
+ expect(d.sendTextReply).toHaveBeenCalledWith(
89
+ "tenant-token",
90
+ "chat-1",
91
+ expect.stringContaining("重置成功"),
92
+ );
93
+ expect(d.sendCardReply).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("does not consume a reset credit and sends a cancellation message when user declines", async () => {
97
+ const d = deps();
98
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
99
+
100
+ await expect(handleCodexResetCardAction(
101
+ event({
102
+ action: "codex_reset_confirm",
103
+ decision: "no",
104
+ parentMessageId: "usage-message",
105
+ requestId: "request-1",
106
+ }, "confirm-message"),
107
+ d,
108
+ )).resolves.toBe(true);
109
+
110
+ expect(d.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled();
111
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
112
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
113
+ expect(d.sendTextReply).toHaveBeenCalledWith(
114
+ "tenant-token",
115
+ "chat-1",
116
+ "Codex 主动重置:用户取消了重置。",
117
+ );
118
+ expect(d.sendCardReply).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("can collapse a confirmation card using context.message_id fallback", async () => {
122
+ const d = deps();
123
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
124
+
125
+ await expect(handleCodexResetCardAction({
126
+ event: {
127
+ context: {
128
+ message_id: "confirm-message-from-context",
129
+ open_chat_id: "chat-1",
130
+ },
131
+ action: {
132
+ value: {
133
+ action: "codex_reset_confirm",
134
+ decision: "no",
135
+ parentMessageId: "usage-message",
136
+ requestId: "request-1",
137
+ },
138
+ },
139
+ },
140
+ }, d)).resolves.toBe(true);
141
+
142
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
143
+ expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message-from-context");
144
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message-from-context");
145
+ });
146
+ });
@@ -98,10 +98,10 @@ function createHangingMockCursorProcess(args: {
98
98
  pid: undefined,
99
99
  });
100
100
 
101
- setTimeout(() => {
101
+ queueMicrotask(() => {
102
102
  if (args.stdout) stdout.write(args.stdout);
103
103
  if (args.stderr) stderr.write(args.stderr);
104
- }, 0);
104
+ });
105
105
 
106
106
  return child;
107
107
  }
@@ -744,6 +744,33 @@ describe("Cursor adapter process failures", () => {
744
744
  expect(message).not.toContain("登录态已失效");
745
745
  });
746
746
 
747
+ it("formats empty stdout action-required stderr with the actionable Cursor error", () => {
748
+ const message = formatCursorAgentEmptyOutputMessage({
749
+ exitCode: 1,
750
+ stdoutLength: 0,
751
+ stderr: "ActionRequiredError: You have an unpaid invoice Your team has an unpaid invoice. Please contact your team administrator to pay your invoice and continue using Cursor.\n",
752
+ });
753
+
754
+ expect(message).toContain("底层命令异常退出");
755
+ expect(message).toContain("[Cursor stderr] exit=1");
756
+ expect(message).toContain("ActionRequiredError");
757
+ expect(message).toContain("unpaid invoice");
758
+ expect(message).toContain("team administrator");
759
+ });
760
+
761
+ it("redacts obvious secrets from visible Cursor stderr", () => {
762
+ const message = formatCursorAgentEmptyOutputMessage({
763
+ exitCode: 1,
764
+ stdoutLength: 0,
765
+ stderr: "Error: failed with token=secret-token-value and Bearer abcdefghijklmnop\n",
766
+ });
767
+
768
+ expect(message).toContain("token=<redacted>");
769
+ expect(message).toContain("Bearer <redacted>");
770
+ expect(message).not.toContain("secret-token-value");
771
+ expect(message).not.toContain("abcdefghijklmnop");
772
+ });
773
+
747
774
  it("does not format a message when stdout is non-empty", () => {
748
775
  expect(
749
776
  formatCursorAgentEmptyOutputMessage({
@@ -786,14 +813,49 @@ describe("Cursor adapter process failures", () => {
786
813
  );
787
814
  });
788
815
 
816
+ it("prompt surfaces action-required stderr before failing the stream", async () => {
817
+ const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
818
+ const spawnImpl = (() =>
819
+ createMockCursorProcess({
820
+ exitCode: 1,
821
+ stderr: "ActionRequiredError: You have an unpaid invoice Your team has an unpaid invoice. Please contact your team administrator to pay your invoice and continue using Cursor.\n",
822
+ })) as CursorSpawnForTest;
823
+ const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
824
+ const iterator = adapter.prompt(
825
+ "sid",
826
+ "[User message]\nhello\n[/User message]",
827
+ "F:/repo",
828
+ )[Symbol.asyncIterator]();
829
+
830
+ const first = await iterator.next();
831
+ expect(first.done).toBe(false);
832
+ expect(first.value.blocks).toHaveLength(1);
833
+ expect(first.value.blocks[0]).toMatchObject({ type: "text_final" });
834
+ expect(first.value.blocks[0]).toHaveProperty(
835
+ "text",
836
+ expect.stringContaining("ActionRequiredError"),
837
+ );
838
+ expect(first.value.blocks[0]).toHaveProperty(
839
+ "text",
840
+ expect.stringContaining("unpaid invoice"),
841
+ );
842
+
843
+ await expect(iterator.next()).rejects.toThrow(
844
+ /Cursor Agent exited without stream-json output/,
845
+ );
846
+ });
847
+
789
848
  it("prompt fails when Cursor emits bad JSON and then leaves stdout idle", async () => {
790
- vi.useFakeTimers();
791
849
  const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
792
850
  const spawnImpl = (() =>
793
851
  createHangingMockCursorProcess({
794
852
  stdout: "{\"type\":\"tool_call\",\"subtype\":\"started\"\n",
795
853
  })) as CursorSpawnForTest;
796
- const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
854
+ const adapter = createCursorAdapter({
855
+ metaStore: store,
856
+ spawn: spawnImpl,
857
+ badJsonIdleTimeoutMs: 10,
858
+ });
797
859
 
798
860
  const pending = (async () => {
799
861
  for await (const _event of adapter.prompt(
@@ -805,9 +867,6 @@ describe("Cursor adapter process failures", () => {
805
867
  }
806
868
  })().catch((error: unknown) => error);
807
869
 
808
- await vi.advanceTimersByTimeAsync(0);
809
- await vi.advanceTimersByTimeAsync(120_000);
810
-
811
870
  const error = await pending;
812
871
  expect(error).toBeInstanceOf(BadJsonIdleTimeoutError);
813
872
  });
@@ -1,60 +1,60 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
-
3
- import { sendTextReply } from "../feishu-api.ts";
4
-
5
- function abortError(): Error {
6
- const err = new Error("aborted");
7
- err.name = "AbortError";
8
- return err;
9
- }
10
-
11
- describe("sendTextReply timeout", () => {
12
- afterEach(() => {
13
- vi.useRealTimers();
14
- vi.restoreAllMocks();
15
- vi.unstubAllGlobals();
16
- });
17
-
18
- it("aborts a hung text send request", async () => {
19
- vi.useFakeTimers();
20
- vi.spyOn(console, "error").mockImplementation(() => {});
21
-
22
- const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
23
- new Promise<Response>((_resolve, reject) => {
24
- init?.signal?.addEventListener("abort", () => reject(abortError()));
25
- })
26
- ));
27
- vi.stubGlobal("fetch", fetchMock);
28
-
29
- const send = expect(sendTextReply("token", "chat-1", "hello"))
30
- .resolves.toBe(false);
31
- await vi.advanceTimersByTimeAsync(15_000);
32
-
33
- await send;
34
- expect(fetchMock).toHaveBeenCalledWith(
35
- expect.stringContaining("/im/v1/messages?receive_id_type=chat_id"),
36
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
37
- );
38
- });
39
-
40
- it("also aborts when response body reading hangs", async () => {
41
- vi.useFakeTimers();
42
- vi.spyOn(console, "error").mockImplementation(() => {});
43
-
44
- const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
45
- Promise.resolve({
46
- status: 200,
47
- text: () => new Promise<string>((_resolve, reject) => {
48
- init?.signal?.addEventListener("abort", () => reject(abortError()));
49
- }),
50
- } as Response)
51
- ));
52
- vi.stubGlobal("fetch", fetchMock);
53
-
54
- const send = expect(sendTextReply("token", "chat-2", "hello"))
55
- .resolves.toBe(false);
56
- await vi.advanceTimersByTimeAsync(15_000);
57
-
58
- await send;
59
- });
60
- });
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { sendTextReply } from "../feishu-api.ts";
4
+
5
+ function abortError(): Error {
6
+ const err = new Error("aborted");
7
+ err.name = "AbortError";
8
+ return err;
9
+ }
10
+
11
+ describe("sendTextReply timeout", () => {
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ vi.restoreAllMocks();
15
+ vi.unstubAllGlobals();
16
+ });
17
+
18
+ it("aborts a hung text send request", async () => {
19
+ vi.useFakeTimers();
20
+ vi.spyOn(console, "error").mockImplementation(() => {});
21
+
22
+ const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
23
+ new Promise<Response>((_resolve, reject) => {
24
+ init?.signal?.addEventListener("abort", () => reject(abortError()));
25
+ })
26
+ ));
27
+ vi.stubGlobal("fetch", fetchMock);
28
+
29
+ const send = expect(sendTextReply("token", "chat-1", "hello"))
30
+ .resolves.toBe(false);
31
+ await vi.advanceTimersByTimeAsync(15_000);
32
+
33
+ await send;
34
+ expect(fetchMock).toHaveBeenCalledWith(
35
+ expect.stringContaining("/im/v1/messages?receive_id_type=chat_id"),
36
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
37
+ );
38
+ });
39
+
40
+ it("also aborts when response body reading hangs", async () => {
41
+ vi.useFakeTimers();
42
+ vi.spyOn(console, "error").mockImplementation(() => {});
43
+
44
+ const fetchMock = vi.fn((_url: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => (
45
+ Promise.resolve({
46
+ status: 200,
47
+ text: () => new Promise<string>((_resolve, reject) => {
48
+ init?.signal?.addEventListener("abort", () => reject(abortError()));
49
+ }),
50
+ } as Response)
51
+ ));
52
+ vi.stubGlobal("fetch", fetchMock);
53
+
54
+ const send = expect(sendTextReply("token", "chat-2", "hello"))
55
+ .resolves.toBe(false);
56
+ await vi.advanceTimersByTimeAsync(15_000);
57
+
58
+ await send;
59
+ });
60
+ });
@@ -132,15 +132,15 @@ describe("Codex avatar usage battery", () => {
132
132
  mockConfig.cursor.onDemandMonthlyBudget = 1000;
133
133
  });
134
134
 
135
- it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
135
+ it("adds 7-day battery and 5h ring percentages to Codex avatar uploads when both windows exist", async () => {
136
136
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
137
137
  const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
138
138
  const uploadedNames: string[] = [];
139
139
  await writeCodexAuth(homeDir);
140
140
  mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
141
141
  rate_limit: {
142
- primary_window: { used_percent: 37 },
143
- secondary_window: { used_percent: 12 },
142
+ primary_window: { used_percent: 37, limit_window_seconds: 18000 },
143
+ secondary_window: { used_percent: 12, limit_window_seconds: 604800 },
144
144
  },
145
145
  }), { status: 200 }));
146
146
 
@@ -148,7 +148,49 @@ describe("Codex avatar usage battery", () => {
148
148
  const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
149
149
  await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
150
150
 
151
- expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
151
+ expect(uploadedNames).toEqual(["avatar_codex_busy_7d_88_5h_63.jpg"]);
152
+ } finally {
153
+ await rm(homeDir, { recursive: true, force: true });
154
+ await rm(userDataDir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ it("uses a 7-day battery without a 5h ring when the API only returns a 7-day window", async () => {
159
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
160
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
161
+ const uploadedNames: string[] = [];
162
+ await writeCodexAuth(homeDir);
163
+ mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
164
+ rate_limit: {
165
+ primary_window: {
166
+ used_percent: 23,
167
+ limit_window_seconds: 604800,
168
+ reset_after_seconds: 500000,
169
+ reset_at: 1784510226,
170
+ },
171
+ secondary_window: null,
172
+ },
173
+ }), { status: 200 }));
174
+
175
+ try {
176
+ const { getCodexUsageSummary, setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
177
+
178
+ await expect(getCodexUsageSummary()).resolves.toEqual({
179
+ fiveHour: null,
180
+ weekly: {
181
+ usedPercent: 23,
182
+ remainingPercent: 77,
183
+ resetAfterSeconds: 500000,
184
+ resetAtEpochSeconds: 1784510226,
185
+ limitWindowSeconds: 604800,
186
+ },
187
+ rateLimitResetCreditsAvailable: null,
188
+ rateLimitResetCredits: [],
189
+ });
190
+
191
+ await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
192
+
193
+ expect(uploadedNames).toEqual(["avatar_codex_busy_7d_77.jpg"]);
152
194
  } finally {
153
195
  await rm(homeDir, { recursive: true, force: true });
154
196
  await rm(userDataDir, { recursive: true, force: true });
@@ -172,7 +214,7 @@ describe("Codex avatar usage battery", () => {
172
214
  },
173
215
  });
174
216
 
175
- expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
217
+ expect(uploadedNames).toEqual(["avatar_codex_busy_7d_88_5h_63.jpg"]);
176
218
  expect(fetchMock).not.toHaveBeenCalledWith("https://chatgpt.com/backend-api/wham/usage", expect.anything());
177
219
  } finally {
178
220
  await rm(homeDir, { recursive: true, force: true });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, getCodexUsageSummary, consumeCodexRateLimitResetCredit, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
2
+ import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, getCodexUsageSummary, consumeCodexRateLimitResetCredit, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
3
3
  import type { FeishuPlatform } from "../feishu-platform.ts";
4
4
 
5
5
  const realPlatform = getPlatform();
@@ -26,16 +26,16 @@ describe("feishu-platform", () => {
26
26
  createGroupChat: async () => "mock_chat",
27
27
  updateChatInfo: async () => {},
28
28
  getChatInfo: async () => ({ name: "x", description: "y" }),
29
- disbandChat: async () => {},
30
- setChatAvatar: async () => {},
31
- getCodexUsageSummary: async () => ({
32
- fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
33
- weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
34
- rateLimitResetCreditsAvailable: 1,
35
- rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
36
- }),
37
- consumeCodexRateLimitResetCredit: async () => ({ code: "reset", windowsReset: 2 }),
38
- getOrDownloadImage: async () => "/tmp/img.png",
29
+ disbandChat: async () => {},
30
+ setChatAvatar: async () => {},
31
+ getCodexUsageSummary: async () => ({
32
+ fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
33
+ weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
34
+ rateLimitResetCreditsAvailable: 1,
35
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
36
+ }),
37
+ consumeCodexRateLimitResetCredit: async () => ({ code: "reset", windowsReset: 2 }),
38
+ getOrDownloadImage: async () => "/tmp/img.png",
39
39
  verifyAllPermissions: async () => [],
40
40
  reportPermissionResults: realPlatform.reportPermissionResults,
41
41
  extractSessionInfo: realPlatform.extractSessionInfo,
@@ -53,16 +53,16 @@ describe("feishu-platform", () => {
53
53
  expect(await getChatInfo("t", "mock_chat")).toEqual({ name: "x", description: "y" });
54
54
  const perms = await verifyAllPermissions("t");
55
55
  expect(perms).toEqual([]);
56
- const mergeMsgs = await getMergeForwardMessages("t", "om_test");
57
- expect(mergeMsgs).toEqual([]);
58
- expect(await getCodexUsageSummary()).toEqual({
59
- fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
60
- weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
61
- rateLimitResetCreditsAvailable: 1,
62
- rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
63
- });
64
- expect(await consumeCodexRateLimitResetCredit("request-1")).toEqual({ code: "reset", windowsReset: 2 });
65
- } finally {
56
+ const mergeMsgs = await getMergeForwardMessages("t", "om_test");
57
+ expect(mergeMsgs).toEqual([]);
58
+ expect(await getCodexUsageSummary()).toEqual({
59
+ fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
60
+ weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
61
+ rateLimitResetCreditsAvailable: 1,
62
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
63
+ });
64
+ expect(await consumeCodexRateLimitResetCredit("request-1")).toEqual({ code: "reset", windowsReset: 2 });
65
+ } finally {
66
66
  // 恢复到真实实现,避免影响后续测试
67
67
  setPlatform(realPlatform);
68
68
  }
@@ -72,4 +72,4 @@ describe("feishu-platform", () => {
72
72
  setPlatform(realPlatform);
73
73
  expect(getPlatform()).toBe(realPlatform);
74
74
  });
75
- });
75
+ });