chatccc 0.2.203 → 0.2.205

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
@@ -133,7 +133,7 @@ Write-Host ''
133
133
  chatccc
134
134
  ```
135
135
 
136
- 如果一切顺利,浏览器会自动打开 `http://127.0.0.1:18080` 的 Web 配置页面。按页面提示填入飞书 App ID / App Secret,点击"保存并启动"即可。
136
+ 如果一切顺利,系统默认浏览器会自动打开 `http://localhost:18080/` 的 Web 配置页面。按页面提示填入飞书 App ID / App Secret,点击"保存并启动"即可。
137
137
 
138
138
  > **只想装 ChatCCC 本体?** 如果你已经有 Node.js,直接 `npm install -g chatccc && chatccc` 即可,不需要跑上面的完整脚本。
139
139
 
@@ -151,7 +151,7 @@ chatccc
151
151
 
152
152
  旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
153
153
 
154
- 首次启动时,如果还没有有效配置,ChatCCC 会自动打开本地 Web 配置向导(默认 `http://127.0.0.1:18080`)。
154
+ 每次直接运行 `chatccc` 时,无论是否已经完成配置,ChatCCC 默认都会用系统默认浏览器打开本地 Web UI(默认 `http://localhost:18080/`,修改 `config.port` 后跟随实际端口)。可在首次配置向导或管理页的 **Web UI** 设置中关闭;关闭后从下一次直接启动起生效。由 `/restart`、`/update` 或 Web UI 发起的内部重启始终不会重复打开浏览器。Linux 服务器没有 `DISPLAY`/`WAYLAND_DISPLAY` 时会跳过打开,并在终端输出 SSH 隧道访问提示。主页顶部的 **Agent Team** 入口会跳转到独立的 `/agent-team` 页面。
155
155
 
156
156
  #### 从源码运行
157
157
 
@@ -260,11 +260,14 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
260
260
  "appId": "cli_xxxxxxxxxxxx",
261
261
  "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
262
262
  },
263
- "platforms": {
264
- "feishu": { "enabled": true, "platformType": "feishu" },
265
- "ilink": { "enabled": true, "reuseTokenOnStart": true }
266
- },
267
- "chromeDevtools": {
263
+ "platforms": {
264
+ "feishu": { "enabled": true, "platformType": "feishu" },
265
+ "ilink": { "enabled": true, "reuseTokenOnStart": true }
266
+ },
267
+ "webUi": {
268
+ "openOnStart": true
269
+ },
270
+ "chromeDevtools": {
268
271
  "enabled": false,
269
272
  "port": 15166,
270
273
  "chromePath": ""
@@ -305,9 +308,10 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
305
308
  | `feishu.appId` / `feishu.appSecret` | 飞书应用凭证 |
306
309
  | `platforms.feishu.enabled` | 是否启用飞书 |
307
310
  | `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
308
- | `platforms.ilink.enabled` | 是否启用微信 iLink |
309
- | `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
310
- | `chromeDevtools.enabled` | 是否启用常驻 Chrome CDP;默认 false |
311
+ | `platforms.ilink.enabled` | 是否启用微信 iLink |
312
+ | `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
313
+ | `webUi.openOnStart` | 直接启动时是否打开系统默认浏览器;默认 true,内部重启始终跳过 |
314
+ | `chromeDevtools.enabled` | 是否启用常驻 Chrome CDP;默认 false |
311
315
  | `chromeDevtools.port` | Chrome CDP 端口;默认 15166 |
312
316
  | `chromeDevtools.chromePath` | Chrome 可执行文件路径;留空时自动探测 |
313
317
  | `port` | 本地 Web 配置面板和中继服务端口 |
@@ -3,11 +3,14 @@
3
3
  "appId": "",
4
4
  "appSecret": ""
5
5
  },
6
- "platforms": {
7
- "feishu": { "enabled": true, "platformType": "feishu" },
8
- "ilink": { "enabled": true, "reuseTokenOnStart": true }
9
- },
10
- "chromeDevtools": {
6
+ "platforms": {
7
+ "feishu": { "enabled": true, "platformType": "feishu" },
8
+ "ilink": { "enabled": true, "reuseTokenOnStart": true }
9
+ },
10
+ "webUi": {
11
+ "openOnStart": true
12
+ },
13
+ "chromeDevtools": {
11
14
  "enabled": false,
12
15
  "port": 15166,
13
16
  "chromePath": ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.203",
3
+ "version": "0.2.205",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createAgentActivityTracker,
5
+ formatAgentActivityTitle,
6
+ updateAgentActivity,
7
+ } from "../agent-activity.ts";
8
+
9
+ describe("agent activity", () => {
10
+ it("starts with an explicit startup status", () => {
11
+ const tracker = createAgentActivityTracker(1_000);
12
+
13
+ expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在启动 Agent · 3秒");
14
+ });
15
+
16
+ it("keeps the thinking timer stable across repeated thinking blocks", () => {
17
+ const tracker = createAgentActivityTracker(1_000);
18
+
19
+ expect(updateAgentActivity(tracker, { type: "thinking", thinking: "first" }, 2_000)).toBe(true);
20
+ expect(updateAgentActivity(tracker, { type: "thinking", thinking: "second" }, 5_000)).toBe(false);
21
+ expect(formatAgentActivityTitle(tracker.activity, 8_000)).toBe("思考中 · 6秒");
22
+ });
23
+
24
+ it("shows the active tool name and elapsed time", () => {
25
+ const tracker = createAgentActivityTracker(1_000);
26
+
27
+ updateAgentActivity(tracker, {
28
+ type: "tool_use",
29
+ id: "tool-1",
30
+ name: "Bash",
31
+ input: { command: "npm test" },
32
+ }, 3_000);
33
+
34
+ expect(formatAgentActivityTitle(tracker.activity, 38_000)).toBe("正在执行 Bash · 35秒");
35
+ });
36
+
37
+ it("tracks parallel tools and keeps the remaining tool after one result", () => {
38
+ const tracker = createAgentActivityTracker(1_000);
39
+
40
+ updateAgentActivity(tracker, { type: "tool_use", id: "read", name: "Read", input: {} }, 2_000);
41
+ updateAgentActivity(tracker, { type: "tool_use", id: "grep", name: "Grep", input: {} }, 3_000);
42
+ expect(formatAgentActivityTitle(tracker.activity, 4_000)).toBe("正在执行 Read 等 2 项 · 2秒");
43
+
44
+ updateAgentActivity(tracker, {
45
+ type: "tool_result",
46
+ tool_use_id: "read",
47
+ content: "done",
48
+ }, 5_000);
49
+ expect(formatAgentActivityTitle(tracker.activity, 7_000)).toBe("正在执行 Grep · 4秒");
50
+
51
+ updateAgentActivity(tracker, {
52
+ type: "tool_result",
53
+ tool_use_id: "grep",
54
+ content: "done",
55
+ }, 8_000);
56
+ expect(formatAgentActivityTitle(tracker.activity, 10_000)).toBe("正在处理工具结果 · 2秒");
57
+ });
58
+
59
+ it("switches to response and context-compaction statuses", () => {
60
+ const tracker = createAgentActivityTracker(1_000);
61
+
62
+ updateAgentActivity(tracker, { type: "text", text: "answer" }, 2_000);
63
+ expect(formatAgentActivityTitle(tracker.activity, 3_000)).toBe("正在生成回复 · 1秒");
64
+
65
+ updateAgentActivity(tracker, {
66
+ type: "compact_boundary",
67
+ trigger: "auto",
68
+ pre_tokens: 100_000,
69
+ }, 4_000);
70
+ expect(formatAgentActivityTitle(tracker.activity, 5_000)).toBe("正在整理上下文 · 1秒");
71
+ });
72
+
73
+ it("formats longer elapsed time without decorative animation", () => {
74
+ expect(formatAgentActivityTitle({ kind: "thinking", startedAt: 1_000 }, 74_000)).toBe("思考中 · 1分13秒");
75
+ });
76
+ });
@@ -76,12 +76,12 @@ describe("normalizeCodexMessage", () => {
76
76
  status: "in_progress",
77
77
  },
78
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
- });
79
+ expect(result).not.toBeNull();
80
+ expect(result!.type).toBe("assistant");
81
+ expect(result!.blocks).toEqual([
82
+ { type: "tool_use", id: "item_0", name: "Bash", input: { command: "powershell.exe -Command ls" } },
83
+ ]);
84
+ });
85
85
 
86
86
  it("normalizes command_execution completion as tool_result (success)", () => {
87
87
  const result = normalizeCodexMessage({
@@ -302,4 +302,4 @@ describe("createCodexAdapter", () => {
302
302
  });
303
303
  expect(await adapter.getSessionInfo("sid-C")).toBeUndefined();
304
304
  });
305
- });
305
+ });
@@ -38,10 +38,11 @@ import {
38
38
  // 原地切换复用同一 server,重新读端口只会引入混乱)
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
- const baseAppConfig: AppConfig = {
42
- feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
43
- platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
44
- chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
41
+ const baseAppConfig: AppConfig = {
42
+ feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
43
+ platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
44
+ webUi: { openOnStart: true },
45
+ chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
45
46
  port: 18080,
46
47
  gitTimeoutSeconds: 180,
47
48
  allowInterrupt: false,
@@ -216,7 +217,7 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
216
217
  expect(config.codex.path).toBe("/refresh/codex");
217
218
  });
218
219
 
219
- it("刷新 chromeDevtools 配置但保持 config 引用", () => {
220
+ it("刷新 chromeDevtools 配置但保持 config 引用", () => {
220
221
  const refBefore = config;
221
222
 
222
223
  applyLoadedConfig({
@@ -226,7 +227,19 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
226
227
 
227
228
  expect(config).toBe(refBefore);
228
229
  expect(config.chromeDevtools).toEqual({ enabled: true, port: 15166, chromePath: "C:/Chrome/chrome.exe" });
229
- });
230
+ });
231
+
232
+ it("刷新 webUi 启动开关但保持 config 引用", () => {
233
+ const refBefore = config;
234
+
235
+ applyLoadedConfig({
236
+ ...structuredClone(baseAppConfig),
237
+ webUi: { openOnStart: false },
238
+ });
239
+
240
+ expect(config).toBe(refBefore);
241
+ expect(config.webUi).toEqual({ openOnStart: false });
242
+ });
230
243
 
231
244
  it("空 feishu 凭证也能正确刷入(向导回滚到空的反向场景)", () => {
232
245
  applyLoadedConfig({
@@ -59,7 +59,16 @@ describe("config.sample.json", () => {
59
59
  expect(sample.chromeDevtools?.enabled).toBe(false);
60
60
  expect(sample.chromeDevtools?.port).toBe(15166);
61
61
  expect(sample.chromeDevtools?.chromePath).toBe("");
62
- });
62
+ });
63
+
64
+ it("opens the Web UI on direct startup by default", () => {
65
+ const configSamplePath = join(process.cwd(), "config.sample.json");
66
+ const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
67
+ webUi?: { openOnStart?: unknown };
68
+ };
69
+
70
+ expect(sample.webUi?.openOnStart).toBe(true);
71
+ });
63
72
 
64
73
  it("keeps raw stream logs disabled by default for every agent", () => {
65
74
  const configSamplePath = join(process.cwd(), "config.sample.json");
@@ -660,11 +660,11 @@ describe("Cursor stream fixture - 端到端不重复", () => {
660
660
  session_id: "sid",
661
661
  timestamp_ms: 1000,
662
662
  } as Parameters<typeof normalizeCursorMessage>[0]);
663
- expect(result).not.toBeNull();
664
- expect(result!.blocks).toEqual([
665
- { type: "tool_use", name: "Bash", input: { command: "ls" } },
666
- ]);
667
- });
663
+ expect(result).not.toBeNull();
664
+ expect(result!.blocks).toEqual([
665
+ { type: "tool_use", id: "toolu_abc", name: "Bash", input: { command: "ls" } },
666
+ ]);
667
+ });
668
668
 
669
669
  it("normalizes tool_call completed → tool_result block (success)", () => {
670
670
  const result = normalizeCursorMessage({
@@ -4,10 +4,16 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  // mock stream-state 以支持在测试中控制累积长度
7
- const mockStreamStates = new Map<string, {
8
- accumulatedContent: string;
9
- finalReply: string;
10
- status?: "running" | "done" | "stopped" | "error";
7
+ const mockStreamStates = new Map<string, {
8
+ accumulatedContent: string;
9
+ finalReply: string;
10
+ activity?: {
11
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
12
+ startedAt: number;
13
+ toolName?: string;
14
+ toolCount?: number;
15
+ };
16
+ status?: "running" | "done" | "stopped" | "error";
11
17
  turnCount?: number;
12
18
  finalReplySentTurn?: number;
13
19
  finalReplySentAt?: number;
@@ -18,8 +24,9 @@ vi.mock("../stream-state.ts", () => ({
18
24
  if (!state) return null;
19
25
  return {
20
26
  sessionId: sid,
21
- accumulatedContent: state.accumulatedContent,
22
- finalReply: state.finalReply,
27
+ accumulatedContent: state.accumulatedContent,
28
+ finalReply: state.finalReply,
29
+ activity: state.activity,
23
30
  finalReplySentTurn: state.finalReplySentTurn,
24
31
  finalReplySentAt: state.finalReplySentAt,
25
32
  status: state.status ?? "running",
@@ -34,24 +41,31 @@ vi.mock("../stream-state.ts", () => ({
34
41
  writeStreamState: async (state: {
35
42
  sessionId: string;
36
43
  accumulatedContent: string;
37
- finalReply: string;
44
+ finalReply: string;
45
+ activity?: {
46
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
47
+ startedAt: number;
48
+ toolName?: string;
49
+ toolCount?: number;
50
+ };
38
51
  status?: "running" | "done" | "stopped" | "error";
39
52
  turnCount?: number;
40
53
  finalReplySentTurn?: number;
41
54
  finalReplySentAt?: number;
42
55
  }) => {
43
56
  mockStreamStates.set(state.sessionId, {
44
- accumulatedContent: state.accumulatedContent,
45
- finalReply: state.finalReply,
57
+ accumulatedContent: state.accumulatedContent,
58
+ finalReply: state.finalReply,
59
+ activity: state.activity,
46
60
  status: state.status,
47
61
  turnCount: state.turnCount,
48
62
  finalReplySentTurn: state.finalReplySentTurn,
49
63
  finalReplySentAt: state.finalReplySentAt,
50
64
  });
51
65
  },
52
- createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
53
- sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
54
- }),
66
+ createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
67
+ sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
68
+ }),
55
69
  isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
56
70
  markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
57
71
  const state = mockStreamStates.get(sid);
@@ -569,7 +583,7 @@ describe("runAgentSession process monitor", () => {
569
583
  });
570
584
  });
571
585
 
572
- describe("unified display loop WeChat delta", () => {
586
+ describe("unified display loop WeChat delta", () => {
573
587
  beforeEach(() => {
574
588
  vi.useFakeTimers();
575
589
  resetState();
@@ -636,10 +650,76 @@ describe("unified display loop WeChat delta", () => {
636
650
  "chat-wechat",
637
651
  "tool output",
638
652
  );
639
- });
640
- });
641
-
642
- describe("unified display loop terminal card update", () => {
653
+ });
654
+ });
655
+
656
+ describe("unified display loop activity status", () => {
657
+ beforeEach(() => {
658
+ vi.useFakeTimers();
659
+ vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
660
+ resetState();
661
+ resetBindingState();
662
+ mockStreamStates.clear();
663
+ });
664
+
665
+ afterEach(() => {
666
+ stopUnifiedDisplayLoop();
667
+ resetBindingState();
668
+ vi.useRealTimers();
669
+ });
670
+
671
+ it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
672
+ const platform = mockPlatform("feishu");
673
+ setSessionPlatform(platform);
674
+
675
+ bindChatToSession("sid-activity", "chat-activity");
676
+ recordLastActiveChat("sid-activity", "chat-activity");
677
+ sessionInfoMap.set("chat-activity", {
678
+ sessionId: "sid-activity",
679
+ turnCount: 1,
680
+ lastContextTokens: 0,
681
+ startTime: Date.now() - 12_000,
682
+ tool: "codex",
683
+ });
684
+ displayCards.set("chat-activity", {
685
+ cardId: "card-activity",
686
+ sequence: 1,
687
+ cardBusy: false,
688
+ cardCreatedAt: Date.now(),
689
+ lastSentContent: "",
690
+ streamErrorNotified: false,
691
+ sessionId: "sid-activity",
692
+ turnCount: 1,
693
+ dotCount: 0,
694
+ });
695
+ mockStreamStates.set("sid-activity", {
696
+ accumulatedContent: "正在检查日志",
697
+ finalReply: "",
698
+ activity: {
699
+ kind: "tool",
700
+ startedAt: Date.now() - 12_000,
701
+ toolName: "Shell",
702
+ toolCount: 1,
703
+ },
704
+ status: "running",
705
+ turnCount: 1,
706
+ });
707
+
708
+ startUnifiedDisplayLoop();
709
+ await vi.advanceTimersByTimeAsync(3_000);
710
+
711
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
712
+ expect(payload).toBeTypeOf("string");
713
+ const card = JSON.parse(payload as string) as {
714
+ header: { title: { content: string } };
715
+ body: { elements: Array<{ tag: string; content?: string }> };
716
+ };
717
+ expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
718
+ expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
719
+ });
720
+ });
721
+
722
+ describe("unified display loop terminal card update", () => {
643
723
  beforeEach(() => {
644
724
  vi.useFakeTimers();
645
725
  resetState();
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ INTERNAL_RESTART_ENV_VAR,
5
+ buildWebUiUrl,
6
+ createInternalRestartEnv,
7
+ openWebUiInDefaultBrowser,
8
+ shouldAutoOpenWebUi,
9
+ } from "../startup-lifecycle.ts";
10
+
11
+ describe("ChatCCC startup lifecycle", () => {
12
+ it("opens the Web UI by default for a direct start", () => {
13
+ expect(shouldAutoOpenWebUi({ env: {} })).toBe(true);
14
+ expect(shouldAutoOpenWebUi({ env: {}, openOnStart: true })).toBe(true);
15
+ });
16
+
17
+ it("respects the persisted openOnStart preference", () => {
18
+ expect(shouldAutoOpenWebUi({ env: {}, openOnStart: false })).toBe(false);
19
+ });
20
+
21
+ it("never opens the Web UI for an internal restart", () => {
22
+ expect(shouldAutoOpenWebUi({
23
+ env: { [INTERNAL_RESTART_ENV_VAR]: "1" },
24
+ openOnStart: true,
25
+ })).toBe(false);
26
+ });
27
+
28
+ it("marks internal restart children without losing the inherited environment", () => {
29
+ expect(createInternalRestartEnv({ PATH: "test-path", CUSTOM: "value" })).toEqual({
30
+ PATH: "test-path",
31
+ CUSTOM: "value",
32
+ [INTERNAL_RESTART_ENV_VAR]: "1",
33
+ });
34
+ });
35
+
36
+ it("uses localhost and the configured port for the Web UI URL", () => {
37
+ expect(buildWebUiUrl(18080)).toBe("http://localhost:18080/");
38
+ expect(buildWebUiUrl(18081)).toBe("http://localhost:18081/");
39
+ });
40
+
41
+ it("opens the configured URL in the Windows default browser", () => {
42
+ const child = {
43
+ on: vi.fn().mockReturnThis(),
44
+ unref: vi.fn(),
45
+ };
46
+ const spawnImpl = vi.fn(() => child as never);
47
+
48
+ expect(openWebUiInDefaultBrowser(18081, {
49
+ platform: "win32",
50
+ spawnImpl,
51
+ })).toBe(true);
52
+ expect(spawnImpl).toHaveBeenCalledWith(
53
+ "cmd.exe",
54
+ ["/c", "start", "", "http://localhost:18081/"],
55
+ {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ windowsHide: true,
59
+ },
60
+ );
61
+ expect(child.on).toHaveBeenCalledWith("error", expect.any(Function));
62
+ expect(child.unref).toHaveBeenCalledOnce();
63
+ });
64
+
65
+ it("opens the configured URL with xdg-open on a Linux desktop", () => {
66
+ const child = {
67
+ on: vi.fn().mockReturnThis(),
68
+ unref: vi.fn(),
69
+ };
70
+ const spawnImpl = vi.fn(() => child as never);
71
+
72
+ expect(openWebUiInDefaultBrowser(18080, {
73
+ platform: "linux",
74
+ env: { DISPLAY: ":0" },
75
+ spawnImpl,
76
+ })).toBe(true);
77
+ expect(spawnImpl).toHaveBeenCalledWith(
78
+ "xdg-open",
79
+ ["http://localhost:18080/"],
80
+ { detached: true, stdio: "ignore" },
81
+ );
82
+ });
83
+
84
+ it("skips xdg-open and prints an SSH tunnel hint on headless Linux", () => {
85
+ const spawnImpl = vi.fn();
86
+ const onInfo = vi.fn();
87
+
88
+ expect(openWebUiInDefaultBrowser(18080, {
89
+ platform: "linux",
90
+ env: {},
91
+ spawnImpl,
92
+ onInfo,
93
+ })).toBe(false);
94
+ expect(spawnImpl).not.toHaveBeenCalled();
95
+ expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("ssh -L 18080:127.0.0.1:18080"));
96
+ expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("http://localhost:18080/"));
97
+ });
98
+ });
@@ -1,11 +1,44 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
1
3
  import { describe, it, expect } from "vitest";
2
4
  import {
5
+ AGENT_TEAM_PAGE_HTML,
3
6
  PAGE_HTML,
4
7
  chooseStartPath,
8
+ createUiRouter,
5
9
  getRestartRequiredReasons,
6
10
  unflattenConfig,
7
11
  } from "../web-ui.ts";
8
12
 
13
+ describe("Agent Team page", () => {
14
+ it("adds a prominent global entry to the ChatCCC page", () => {
15
+ expect(PAGE_HTML).toContain('href="/agent-team"');
16
+ expect(PAGE_HTML).toContain('class="agent-team-entry"');
17
+ expect(PAGE_HTML).toContain("Agent Team");
18
+ expect(PAGE_HTML).toContain("linear-gradient");
19
+ });
20
+
21
+ it("contains only the Agent Team title as page content", () => {
22
+ expect(AGENT_TEAM_PAGE_HTML).toContain("<title>Agent Team</title>");
23
+ expect(AGENT_TEAM_PAGE_HTML).toContain("<h1>Agent Team</h1>");
24
+ expect(AGENT_TEAM_PAGE_HTML).not.toContain("dashboard-view");
25
+ expect(AGENT_TEAM_PAGE_HTML).not.toContain("wizard-view");
26
+ });
27
+
28
+ it("serves the Agent Team page from its own route", async () => {
29
+ const server = createServer(createUiRouter());
30
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
31
+ const port = (server.address() as AddressInfo).port;
32
+ try {
33
+ const response = await fetch(`http://127.0.0.1:${port}/agent-team`);
34
+ expect(response.status).toBe(200);
35
+ expect(await response.text()).toBe(AGENT_TEAM_PAGE_HTML);
36
+ } finally {
37
+ await new Promise<void>((resolve, reject) => server.close((err) => err ? reject(err) : resolve()));
38
+ }
39
+ });
40
+ });
41
+
9
42
  describe("unflattenConfig", () => {
10
43
  it("maps Claude subagent model into claude.subagentModel", () => {
11
44
  expect(
@@ -66,6 +99,18 @@ describe("unflattenConfig", () => {
66
99
  },
67
100
  });
68
101
  });
102
+
103
+ it("maps the Web UI startup preference into webUi config", () => {
104
+ expect(
105
+ unflattenConfig({
106
+ CHATCCC_WEB_UI_OPEN_ON_START: false,
107
+ }),
108
+ ).toEqual({
109
+ webUi: {
110
+ openOnStart: false,
111
+ },
112
+ });
113
+ });
69
114
  });
70
115
 
71
116
  describe("getRestartRequiredReasons", () => {
@@ -75,6 +120,7 @@ describe("getRestartRequiredReasons", () => {
75
120
  feishu: { enabled: true, platformType: "feishu" },
76
121
  ilink: { enabled: true, reuseTokenOnStart: true },
77
122
  },
123
+ webUi: { openOnStart: true },
78
124
  chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
79
125
  port: 18080,
80
126
  claude: {
@@ -142,6 +188,12 @@ describe("getRestartRequiredReasons", () => {
142
188
  });
143
189
 
144
190
  describe("dashboard edit modal", () => {
191
+ it("keeps the embedded dashboard script syntactically valid", () => {
192
+ const script = PAGE_HTML.match(/<script>([\s\S]*)<\/script>/)?.[1];
193
+ expect(script).toBeTruthy();
194
+ expect(() => new Function(script ?? "")).not.toThrow();
195
+ });
196
+
145
197
  it("shows the edit modal and overlay when a section edit button is clicked", () => {
146
198
  expect(PAGE_HTML).toContain("function editSection(section)");
147
199
  expect(PAGE_HTML).toContain("document.getElementById('edit-modal').classList.remove('hidden');");
@@ -158,6 +210,13 @@ describe("dashboard edit modal", () => {
158
210
  expect(PAGE_HTML).toContain("生效范围:保存后下一条消息或下个新会话生效");
159
211
  expect(PAGE_HTML).toContain("生效范围:飞书开关、App ID、App Secret 或平台类型变更需要重启 ChatCCC");
160
212
  });
213
+
214
+ it("shows the Web UI startup switch in both the wizard and dashboard", () => {
215
+ expect(PAGE_HTML).toContain('id="field-CHATCCC_WEB_UI_OPEN_ON_START"');
216
+ expect(PAGE_HTML).toContain('id="cfg-WEB_UI_OPEN_ON_START"');
217
+ expect(PAGE_HTML).toContain("editSection('webUi')");
218
+ expect(PAGE_HTML).toContain("下次直接启动生效;内部重启始终不打开");
219
+ });
161
220
  });
162
221
 
163
222
  // ---------------------------------------------------------------------------
@@ -153,6 +153,7 @@ export function normalizeCodexMessage(
153
153
  blocks: [
154
154
  {
155
155
  type: "tool_use",
156
+ id: msg.item.id,
156
157
  name: "Bash",
157
158
  input: { command: msg.item.command },
158
159
  },
@@ -297,6 +297,7 @@ export function normalizeCursorMessage(
297
297
  blocks: [
298
298
  {
299
299
  type: "tool_use",
300
+ id: msg.call_id,
300
301
  name: mapToolCallKey(toolKey),
301
302
  input: toolData.args ?? {},
302
303
  },