chatccc 0.2.16 → 0.2.18
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 +105 -111
- package/bin/chatccc.mjs +2 -14
- package/config.sample.json +26 -0
- package/images/avatars/status_busy.png +0 -0
- package/images/avatars/status_idle.png +0 -0
- package/images/avatars/status_new.png +0 -0
- package/images/img_readme_0.jpg +0 -0
- package/images/img_readme_1.jpg +0 -0
- package/images/img_readme_callback.png +0 -0
- package/images/img_readme_event.png +0 -0
- package/images/img_readme_permission.png +0 -0
- package/package.json +13 -12
- package/src/__tests__/cards.test.ts +3 -3
- package/src/__tests__/claude-adapter.test.ts +301 -10
- package/src/__tests__/config-reload.test.ts +171 -0
- package/src/__tests__/config.test.ts +259 -2
- package/src/__tests__/crash-logging.test.ts +210 -0
- package/src/__tests__/session.test.ts +2 -2
- package/src/__tests__/web-ui.test.ts +178 -0
- package/src/adapters/claude-adapter.ts +57 -8
- package/src/adapters/codex-adapter.ts +294 -293
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +412 -178
- package/src/exit-banner.ts +12 -2
- package/src/feishu-api.ts +4 -15
- package/src/index.ts +178 -64
- package/src/session.ts +27 -12
- package/src/shared.ts +189 -16
- package/src/web-ui.ts +1591 -0
- package/.env.example +0 -44
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
|
|
2
2
|
import { normalizeSdkMessage, createClaudeAdapter } from "../adapters/claude-adapter.ts";
|
|
3
3
|
import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
|
|
4
4
|
|
|
@@ -329,7 +329,7 @@ describe("createClaudeAdapter", () => {
|
|
|
329
329
|
const adapter = createClaudeAdapter({
|
|
330
330
|
model: "claude-sonnet-4-6",
|
|
331
331
|
effort: "high",
|
|
332
|
-
|
|
332
|
+
isEmpty: (v) => v.trim() === "",
|
|
333
333
|
});
|
|
334
334
|
expect(adapter.displayName).toBe("Claude Code");
|
|
335
335
|
expect(adapter.sessionDescPrefix).toBe("Claude Code Session:");
|
|
@@ -350,7 +350,7 @@ describe("createClaudeAdapter", () => {
|
|
|
350
350
|
const adapter = createClaudeAdapter({
|
|
351
351
|
model: "claude-sonnet-4-6",
|
|
352
352
|
effort: "high",
|
|
353
|
-
|
|
353
|
+
isEmpty: () => false,
|
|
354
354
|
});
|
|
355
355
|
|
|
356
356
|
const result = await adapter.createSession("/tmp");
|
|
@@ -372,7 +372,7 @@ describe("createClaudeAdapter", () => {
|
|
|
372
372
|
const adapter = createClaudeAdapter({
|
|
373
373
|
model: "claude-sonnet-4-6",
|
|
374
374
|
effort: "high",
|
|
375
|
-
|
|
375
|
+
isEmpty: () => false,
|
|
376
376
|
});
|
|
377
377
|
|
|
378
378
|
await expect(adapter.createSession("/tmp")).rejects.toThrow(
|
|
@@ -397,7 +397,7 @@ describe("createClaudeAdapter", () => {
|
|
|
397
397
|
const adapter = createClaudeAdapter({
|
|
398
398
|
model: "claude-sonnet-4-6",
|
|
399
399
|
effort: "high",
|
|
400
|
-
|
|
400
|
+
isEmpty: () => false,
|
|
401
401
|
});
|
|
402
402
|
|
|
403
403
|
await adapter.createSession("/tmp");
|
|
@@ -428,7 +428,7 @@ describe("createClaudeAdapter", () => {
|
|
|
428
428
|
const adapter = createClaudeAdapter({
|
|
429
429
|
model: "claude-sonnet-4-6",
|
|
430
430
|
effort: "high",
|
|
431
|
-
|
|
431
|
+
isEmpty: () => false,
|
|
432
432
|
});
|
|
433
433
|
|
|
434
434
|
const messages: UnifiedStreamMessage[] = [];
|
|
@@ -468,7 +468,7 @@ describe("createClaudeAdapter", () => {
|
|
|
468
468
|
const adapter = createClaudeAdapter({
|
|
469
469
|
model: "claude-sonnet-4-6",
|
|
470
470
|
effort: "high",
|
|
471
|
-
|
|
471
|
+
isEmpty: () => false,
|
|
472
472
|
});
|
|
473
473
|
|
|
474
474
|
const messages: UnifiedStreamMessage[] = [];
|
|
@@ -492,7 +492,7 @@ describe("createClaudeAdapter", () => {
|
|
|
492
492
|
const adapter = createClaudeAdapter({
|
|
493
493
|
model: "claude-sonnet-4-6",
|
|
494
494
|
effort: "high",
|
|
495
|
-
|
|
495
|
+
isEmpty: () => false,
|
|
496
496
|
});
|
|
497
497
|
|
|
498
498
|
const info = await adapter.getSessionInfo("sid");
|
|
@@ -510,7 +510,7 @@ describe("createClaudeAdapter", () => {
|
|
|
510
510
|
const adapter = createClaudeAdapter({
|
|
511
511
|
model: "claude-sonnet-4-6",
|
|
512
512
|
effort: "high",
|
|
513
|
-
|
|
513
|
+
isEmpty: () => false,
|
|
514
514
|
});
|
|
515
515
|
|
|
516
516
|
const info = await adapter.getSessionInfo("nonexistent");
|
|
@@ -521,9 +521,300 @@ describe("createClaudeAdapter", () => {
|
|
|
521
521
|
const adapter = createClaudeAdapter({
|
|
522
522
|
model: "claude-sonnet-4-6",
|
|
523
523
|
effort: "high",
|
|
524
|
-
|
|
524
|
+
isEmpty: () => false,
|
|
525
525
|
});
|
|
526
526
|
|
|
527
527
|
await expect(adapter.closeSession("any-sid")).resolves.toBeUndefined();
|
|
528
528
|
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// createClaudeAdapter — sessionOpts 形状护栏
|
|
533
|
+
// 这一组测试只断言 "调用 SDK 时传了什么"。任何对 buildSessionOptions / env
|
|
534
|
+
// 注入逻辑的改动都应该让这组测试继续通过;如有意修改默认行为,请同步更新断言。
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
describe("createClaudeAdapter — sessionOpts 形状", () => {
|
|
538
|
+
let sdk: any;
|
|
539
|
+
|
|
540
|
+
/** 构造一个完成首次 init 即结束的 mock session,用于让 createSession 走完流程。 */
|
|
541
|
+
function setupMockCreateSession(): void {
|
|
542
|
+
const mockSessionObj = mockSession({
|
|
543
|
+
streamNext: vi
|
|
544
|
+
.fn()
|
|
545
|
+
.mockResolvedValueOnce({
|
|
546
|
+
done: false,
|
|
547
|
+
value: { session_id: "sid", type: "system", subtype: "init" },
|
|
548
|
+
})
|
|
549
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
550
|
+
});
|
|
551
|
+
sdk.unstable_v2_createSession.mockReturnValue(mockSessionObj);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function setupMockResumeSession(): void {
|
|
555
|
+
const mockSessionObj = mockSession({
|
|
556
|
+
streamNext: vi
|
|
557
|
+
.fn()
|
|
558
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
559
|
+
});
|
|
560
|
+
sdk.unstable_v2_resumeSession.mockReturnValue(mockSessionObj);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
beforeEach(async () => {
|
|
564
|
+
vi.clearAllMocks();
|
|
565
|
+
sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("createSession 把 cwd / 固定权限选项传给 SDK", async () => {
|
|
569
|
+
setupMockCreateSession();
|
|
570
|
+
const adapter = createClaudeAdapter({
|
|
571
|
+
model: "claude-sonnet-4-6",
|
|
572
|
+
effort: "high",
|
|
573
|
+
isEmpty: () => false,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await adapter.createSession("/work/dir");
|
|
577
|
+
|
|
578
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
579
|
+
expect(opts).toMatchObject({
|
|
580
|
+
cwd: "/work/dir",
|
|
581
|
+
permissionMode: "bypassPermissions",
|
|
582
|
+
allowDangerouslySkipPermissions: true,
|
|
583
|
+
autoCompactEnabled: true,
|
|
584
|
+
settingSources: ["project", "local"],
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("model / effort 非空时被传给 SDK", async () => {
|
|
589
|
+
setupMockCreateSession();
|
|
590
|
+
const adapter = createClaudeAdapter({
|
|
591
|
+
model: "claude-sonnet-4-6",
|
|
592
|
+
effort: "max",
|
|
593
|
+
isEmpty: (v) => v.trim() === "",
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await adapter.createSession("/cwd");
|
|
597
|
+
|
|
598
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
599
|
+
expect(opts.model).toBe("claude-sonnet-4-6");
|
|
600
|
+
expect(opts.effort).toBe("max");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("isEmpty(model) 为 true 时不传 model 字段", async () => {
|
|
604
|
+
setupMockCreateSession();
|
|
605
|
+
const adapter = createClaudeAdapter({
|
|
606
|
+
model: "",
|
|
607
|
+
effort: "high",
|
|
608
|
+
isEmpty: (v) => v.trim() === "",
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
await adapter.createSession("/cwd");
|
|
612
|
+
|
|
613
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
614
|
+
expect(opts).not.toHaveProperty("model");
|
|
615
|
+
expect(opts.effort).toBe("high");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("isEmpty(effort) 为 true 时不传 effort 字段", async () => {
|
|
619
|
+
setupMockCreateSession();
|
|
620
|
+
const adapter = createClaudeAdapter({
|
|
621
|
+
model: "claude-sonnet-4-6",
|
|
622
|
+
effort: "",
|
|
623
|
+
isEmpty: (v) => v.trim() === "",
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
await adapter.createSession("/cwd");
|
|
627
|
+
|
|
628
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
629
|
+
expect(opts.model).toBe("claude-sonnet-4-6");
|
|
630
|
+
expect(opts).not.toHaveProperty("effort");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("prompt() 也按相同规则构造 sessionOpts(resume 路径)", async () => {
|
|
634
|
+
setupMockResumeSession();
|
|
635
|
+
const adapter = createClaudeAdapter({
|
|
636
|
+
model: "claude-sonnet-4-6",
|
|
637
|
+
effort: "max",
|
|
638
|
+
isEmpty: (v) => v.trim() === "",
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const it_ = adapter.prompt("sid", "hi", "/resume/cwd");
|
|
642
|
+
for await (const _ of it_) { /* drain */ }
|
|
643
|
+
|
|
644
|
+
const opts = sdk.unstable_v2_resumeSession.mock.calls[0][1];
|
|
645
|
+
expect(opts).toMatchObject({
|
|
646
|
+
cwd: "/resume/cwd",
|
|
647
|
+
permissionMode: "bypassPermissions",
|
|
648
|
+
autoCompactEnabled: true,
|
|
649
|
+
model: "claude-sonnet-4-6",
|
|
650
|
+
effort: "max",
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// createClaudeAdapter — env 注入(apiKey / baseUrl 通过 SDK env 传递)
|
|
657
|
+
// 行为契约:
|
|
658
|
+
// - apiKey 或 baseUrl 任一非空(trim 后)→ 传 env,且 env 是 process.env
|
|
659
|
+
// 的副本,并按需覆盖 ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL;
|
|
660
|
+
// 其余 process.env 字段保持不变。
|
|
661
|
+
// - 两者都为空 → 完全不传 env 字段,让 SDK 走默认行为(即 process.env)。
|
|
662
|
+
// - 主进程的 process.env 永不被修改(避免污染其他依赖于 env 的代码)。
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
describe("createClaudeAdapter — env 注入", () => {
|
|
666
|
+
let sdk: any;
|
|
667
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
668
|
+
|
|
669
|
+
function setupMockCreateSession(): void {
|
|
670
|
+
const mockSessionObj = mockSession({
|
|
671
|
+
streamNext: vi
|
|
672
|
+
.fn()
|
|
673
|
+
.mockResolvedValueOnce({
|
|
674
|
+
done: false,
|
|
675
|
+
value: { session_id: "sid", type: "system", subtype: "init" },
|
|
676
|
+
})
|
|
677
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
678
|
+
});
|
|
679
|
+
sdk.unstable_v2_createSession.mockReturnValue(mockSessionObj);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
beforeEach(async () => {
|
|
683
|
+
vi.clearAllMocks();
|
|
684
|
+
sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
685
|
+
// 确保每个用例从干净的 process.env 起步
|
|
686
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
687
|
+
delete process.env.ANTHROPIC_BASE_URL;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// 用例之间清掉我们写入的 env,避免互相污染
|
|
691
|
+
// (afterEach 等价物:vitest 在 beforeEach 里 reset 即可)
|
|
692
|
+
afterAll(() => {
|
|
693
|
+
process.env = { ...ORIGINAL_ENV };
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("apiKey / baseUrl 都为空时,不向 SDK 传 env 字段", async () => {
|
|
697
|
+
setupMockCreateSession();
|
|
698
|
+
const adapter = createClaudeAdapter({
|
|
699
|
+
model: "claude-sonnet-4-6",
|
|
700
|
+
effort: "high",
|
|
701
|
+
isEmpty: (v) => v.trim() === "",
|
|
702
|
+
apiKey: "",
|
|
703
|
+
baseUrl: "",
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await adapter.createSession("/cwd");
|
|
707
|
+
|
|
708
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
709
|
+
expect(opts).not.toHaveProperty("env");
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("apiKey / baseUrl 全空白同样视为空", async () => {
|
|
713
|
+
setupMockCreateSession();
|
|
714
|
+
const adapter = createClaudeAdapter({
|
|
715
|
+
model: "claude-sonnet-4-6",
|
|
716
|
+
effort: "high",
|
|
717
|
+
isEmpty: (v) => v.trim() === "",
|
|
718
|
+
apiKey: " ",
|
|
719
|
+
baseUrl: "\t\n",
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
await adapter.createSession("/cwd");
|
|
723
|
+
|
|
724
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
725
|
+
expect(opts).not.toHaveProperty("env");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("apiKey 非空 → 传 env 且覆盖 ANTHROPIC_API_KEY", async () => {
|
|
729
|
+
setupMockCreateSession();
|
|
730
|
+
process.env.SOME_OTHER = "keep-me";
|
|
731
|
+
const adapter = createClaudeAdapter({
|
|
732
|
+
model: "",
|
|
733
|
+
effort: "",
|
|
734
|
+
isEmpty: (v) => v.trim() === "",
|
|
735
|
+
apiKey: "sk-test-key",
|
|
736
|
+
baseUrl: "",
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
await adapter.createSession("/cwd");
|
|
740
|
+
|
|
741
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
742
|
+
expect(opts.env).toBeDefined();
|
|
743
|
+
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-test-key");
|
|
744
|
+
expect(opts.env.SOME_OTHER).toBe("keep-me");
|
|
745
|
+
|
|
746
|
+
delete process.env.SOME_OTHER;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("baseUrl 非空 → 传 env 且覆盖 ANTHROPIC_BASE_URL", async () => {
|
|
750
|
+
setupMockCreateSession();
|
|
751
|
+
const adapter = createClaudeAdapter({
|
|
752
|
+
model: "",
|
|
753
|
+
effort: "",
|
|
754
|
+
isEmpty: (v) => v.trim() === "",
|
|
755
|
+
apiKey: "",
|
|
756
|
+
baseUrl: "https://api.deepseek.com/anthropic",
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
await adapter.createSession("/cwd");
|
|
760
|
+
|
|
761
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
762
|
+
expect(opts.env).toBeDefined();
|
|
763
|
+
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic");
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("apiKey + baseUrl 都设置 → 同时覆盖", async () => {
|
|
767
|
+
setupMockCreateSession();
|
|
768
|
+
const adapter = createClaudeAdapter({
|
|
769
|
+
model: "",
|
|
770
|
+
effort: "",
|
|
771
|
+
isEmpty: (v) => v.trim() === "",
|
|
772
|
+
apiKey: "sk-x",
|
|
773
|
+
baseUrl: "https://gateway.example/anthropic",
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
await adapter.createSession("/cwd");
|
|
777
|
+
|
|
778
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
779
|
+
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-x");
|
|
780
|
+
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("不修改主进程 process.env(永不污染)", async () => {
|
|
784
|
+
setupMockCreateSession();
|
|
785
|
+
const before = { ...process.env };
|
|
786
|
+
|
|
787
|
+
const adapter = createClaudeAdapter({
|
|
788
|
+
model: "",
|
|
789
|
+
effort: "",
|
|
790
|
+
isEmpty: (v) => v.trim() === "",
|
|
791
|
+
apiKey: "sk-should-not-leak",
|
|
792
|
+
baseUrl: "https://should-not-leak/anthropic",
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await adapter.createSession("/cwd");
|
|
796
|
+
|
|
797
|
+
// 调用结束后 process.env 与调用前应保持一致
|
|
798
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe(before.ANTHROPIC_API_KEY);
|
|
799
|
+
expect(process.env.ANTHROPIC_BASE_URL).toBe(before.ANTHROPIC_BASE_URL);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("apiKey 留空但 process.env 已有 ANTHROPIC_API_KEY → 不覆盖、不抹掉", async () => {
|
|
803
|
+
setupMockCreateSession();
|
|
804
|
+
process.env.ANTHROPIC_API_KEY = "from-system-env";
|
|
805
|
+
const adapter = createClaudeAdapter({
|
|
806
|
+
model: "",
|
|
807
|
+
effort: "",
|
|
808
|
+
isEmpty: (v) => v.trim() === "",
|
|
809
|
+
apiKey: "",
|
|
810
|
+
baseUrl: "https://override.example/anthropic",
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await adapter.createSession("/cwd");
|
|
814
|
+
|
|
815
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
816
|
+
// baseUrl 触发了 env 注入;apiKey 为空 → 沿用系统 env
|
|
817
|
+
expect(opts.env.ANTHROPIC_API_KEY).toBe("from-system-env");
|
|
818
|
+
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://override.example/anthropic");
|
|
819
|
+
});
|
|
529
820
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// 注意:这个测试文件是少数几个**故意**直接 import `../config.ts` 的地方
|
|
4
|
+
// (会触发 module-level loadConfig 副作用)。其它测试应优先 import
|
|
5
|
+
// config-utils.ts。这里因为要验证 export let 常量在 applyLoadedConfig
|
|
6
|
+
// 调用后能被刷新,没有更轻量的替代路径。
|
|
7
|
+
import {
|
|
8
|
+
APP_ID,
|
|
9
|
+
APP_SECRET,
|
|
10
|
+
CHATCCC_PORT,
|
|
11
|
+
CLAUDE_API_KEY,
|
|
12
|
+
CLAUDE_BASE_URL,
|
|
13
|
+
CLAUDE_EFFORT,
|
|
14
|
+
CLAUDE_MODEL,
|
|
15
|
+
CURSOR_AGENT_ARGS,
|
|
16
|
+
CURSOR_AGENT_COMMAND,
|
|
17
|
+
GIT_TIMEOUT_MS,
|
|
18
|
+
GIT_TIMEOUT_SECONDS,
|
|
19
|
+
applyLoadedConfig,
|
|
20
|
+
config,
|
|
21
|
+
type AppConfig,
|
|
22
|
+
} from "../config.ts";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
|
|
26
|
+
//
|
|
27
|
+
// 关键护栏:
|
|
28
|
+
// 1. 调用后 export let 的 APP_ID / APP_SECRET 等必须刷新到新值
|
|
29
|
+
// 2. config 这个 export 必须保持**同一个引用**(不能被替换),
|
|
30
|
+
// 否则 codex-adapter 等"直接 import config"的下游会拿不到新值
|
|
31
|
+
// 3. CHATCCC_PORT 不被 reload 改动(setup HTTP server 已经监听原端口,
|
|
32
|
+
// 原地切换复用同一 server,重新读端口只会引入混乱)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const baseAppConfig: AppConfig = {
|
|
36
|
+
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
37
|
+
port: 18080,
|
|
38
|
+
gitTimeoutSeconds: 180,
|
|
39
|
+
claude: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
model: "initial-model",
|
|
42
|
+
effort: "initial-effort",
|
|
43
|
+
apiKey: "sk-initial",
|
|
44
|
+
baseUrl: "https://initial.gw/anthropic",
|
|
45
|
+
},
|
|
46
|
+
cursor: { enabled: true, path: "/initial/cursor", model: "initial-cursor-model" },
|
|
47
|
+
codex: { enabled: true, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
|
|
51
|
+
// 不直接用启动时的 config 引用做 snapshot——它可能已经被前一个 it 改写。
|
|
52
|
+
function resetToBaseline(): void {
|
|
53
|
+
applyLoadedConfig(structuredClone(baseAppConfig));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
resetToBaseline();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
61
|
+
it("更新 APP_ID / APP_SECRET(飞书凭证)", () => {
|
|
62
|
+
expect(APP_ID).toBe("INITIAL_APP");
|
|
63
|
+
expect(APP_SECRET).toBe("INITIAL_SECRET");
|
|
64
|
+
|
|
65
|
+
applyLoadedConfig({
|
|
66
|
+
...structuredClone(baseAppConfig),
|
|
67
|
+
feishu: { appId: "NEW_APP_ID", appSecret: "NEW_APP_SECRET" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ES module live binding:测试模块顶层 import 的 APP_ID 会自动看到新值
|
|
71
|
+
expect(APP_ID).toBe("NEW_APP_ID");
|
|
72
|
+
expect(APP_SECRET).toBe("NEW_APP_SECRET");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
|
|
76
|
+
applyLoadedConfig({
|
|
77
|
+
...structuredClone(baseAppConfig),
|
|
78
|
+
claude: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
model: "deepseek-v4-pro",
|
|
81
|
+
effort: "high",
|
|
82
|
+
apiKey: "sk-newkey",
|
|
83
|
+
baseUrl: "https://gw2.example/anthropic",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(CLAUDE_MODEL).toBe("deepseek-v4-pro");
|
|
88
|
+
expect(CLAUDE_EFFORT).toBe("high");
|
|
89
|
+
expect(CLAUDE_API_KEY).toBe("sk-newkey");
|
|
90
|
+
expect(CLAUDE_BASE_URL).toBe("https://gw2.example/anthropic");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("更新 GIT_TIMEOUT_SECONDS 与 GIT_TIMEOUT_MS(毫秒派生值同步刷新)", () => {
|
|
94
|
+
applyLoadedConfig({
|
|
95
|
+
...structuredClone(baseAppConfig),
|
|
96
|
+
gitTimeoutSeconds: 240,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(GIT_TIMEOUT_SECONDS).toBe(240);
|
|
100
|
+
// 派生值必须跟着更新,否则 /git 仍然按旧 timeout 运行
|
|
101
|
+
expect(GIT_TIMEOUT_MS).toBe(240 * 1000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
|
|
105
|
+
applyLoadedConfig({
|
|
106
|
+
...structuredClone(baseAppConfig),
|
|
107
|
+
cursor: { enabled: true, path: "/x/cursor", model: "claude-3.7-sonnet" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
|
|
111
|
+
expect(CURSOR_AGENT_ARGS).toContain("--model");
|
|
112
|
+
expect(CURSOR_AGENT_ARGS).toContain("claude-3.7-sonnet");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
|
|
116
|
+
applyLoadedConfig({
|
|
117
|
+
...structuredClone(baseAppConfig),
|
|
118
|
+
cursor: { enabled: true, path: "/x/cursor", model: "" },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(CURSOR_AGENT_ARGS).not.toContain("--model");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
|
|
125
|
+
applyLoadedConfig({
|
|
126
|
+
...structuredClone(baseAppConfig),
|
|
127
|
+
cursor: { enabled: true, path: "C:/custom/cursor.exe", model: "" },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("不修改 CHATCCC_PORT(端口在 setup 切换时必须保持不变)", () => {
|
|
134
|
+
const portBefore = CHATCCC_PORT;
|
|
135
|
+
applyLoadedConfig({
|
|
136
|
+
...structuredClone(baseAppConfig),
|
|
137
|
+
port: 19999,
|
|
138
|
+
});
|
|
139
|
+
// 重要:port 字段会刷到 config 对象上(见下个测试),但 CHATCCC_PORT 这个
|
|
140
|
+
// 顶级 export 始终指向 chatccc 启动那一刻的端口,不允许在线切换中更换。
|
|
141
|
+
expect(CHATCCC_PORT).toBe(portBefore);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
146
|
+
it("config 引用保持不变(就地更新),但字段被刷新", () => {
|
|
147
|
+
const refBefore = config;
|
|
148
|
+
|
|
149
|
+
applyLoadedConfig({
|
|
150
|
+
...structuredClone(baseAppConfig),
|
|
151
|
+
feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
|
|
152
|
+
codex: { enabled: true, path: "/refresh/codex", model: "fresh-model", effort: "low" },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
|
|
156
|
+
// 替换引用会破坏它们对 config.codex.* 的访问。
|
|
157
|
+
expect(config).toBe(refBefore);
|
|
158
|
+
expect(config.feishu.appId).toBe("REF_TEST_APP");
|
|
159
|
+
expect(config.codex.path).toBe("/refresh/codex");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("空 feishu 凭证也能正确刷入(向导回滚到空的反向场景)", () => {
|
|
163
|
+
applyLoadedConfig({
|
|
164
|
+
...structuredClone(baseAppConfig),
|
|
165
|
+
feishu: { appId: "", appSecret: "" },
|
|
166
|
+
});
|
|
167
|
+
expect(APP_ID).toBe("");
|
|
168
|
+
expect(APP_SECRET).toBe("");
|
|
169
|
+
expect(config.feishu.appId).toBe("");
|
|
170
|
+
});
|
|
171
|
+
});
|