@calltelemetry/openclaw-linear 0.9.1 → 0.9.2
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 +301 -255
- package/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +147 -0
- package/src/infra/doctor.test.ts +762 -2
- package/src/pipeline/dag-dispatch.test.ts +444 -0
- package/src/pipeline/pipeline.test.ts +1247 -1
- package/src/pipeline/planner.test.ts +457 -3
- package/src/pipeline/planning-state.test.ts +164 -3
- package/src/pipeline/webhook.test.ts +2438 -19
- package/src/tools/planner-tools.test.ts +722 -0
|
@@ -4,8 +4,9 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
|
|
4
4
|
// Mocks (vi.hoisted + vi.mock)
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
|
-
const { runAgentMock } = vi.hoisted(() => ({
|
|
7
|
+
const { runAgentMock, loadRawPromptYamlMock } = vi.hoisted(() => ({
|
|
8
8
|
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
|
|
9
|
+
loadRawPromptYamlMock: vi.fn().mockReturnValue(null),
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
12
|
vi.mock("../agent/agent.js", () => ({
|
|
@@ -16,6 +17,10 @@ vi.mock("../api/linear-api.js", () => ({}));
|
|
|
16
17
|
|
|
17
18
|
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
18
19
|
|
|
20
|
+
vi.mock("./pipeline.js", () => ({
|
|
21
|
+
loadRawPromptYaml: loadRawPromptYamlMock,
|
|
22
|
+
}));
|
|
23
|
+
|
|
19
24
|
// Mock CLI tool runners for cross-model review
|
|
20
25
|
vi.mock("../tools/claude-tool.js", () => ({
|
|
21
26
|
runClaude: vi.fn().mockResolvedValue({ success: true, output: "Claude review feedback" }),
|
|
@@ -135,6 +140,7 @@ function createSession(overrides?: Record<string, unknown>) {
|
|
|
135
140
|
afterEach(() => {
|
|
136
141
|
vi.clearAllMocks();
|
|
137
142
|
runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
|
|
143
|
+
loadRawPromptYamlMock.mockReturnValue(null);
|
|
138
144
|
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
139
145
|
});
|
|
140
146
|
|
|
@@ -305,10 +311,10 @@ describe("runPlanAudit", () => {
|
|
|
305
311
|
|
|
306
312
|
await runPlanAudit(ctx, session);
|
|
307
313
|
|
|
308
|
-
// Agent should run with a review prompt
|
|
314
|
+
// Agent should run with a review prompt containing cross-model feedback
|
|
309
315
|
expect(runAgentMock).toHaveBeenCalledWith(
|
|
310
316
|
expect.objectContaining({
|
|
311
|
-
message: expect.stringContaining("
|
|
317
|
+
message: expect.stringContaining("passed checks"),
|
|
312
318
|
}),
|
|
313
319
|
);
|
|
314
320
|
});
|
|
@@ -450,4 +456,452 @@ describe("runCrossModelReview", () => {
|
|
|
450
456
|
const result = await runCrossModelReview(api, "claude", "test snapshot");
|
|
451
457
|
expect(result).toContain("review unavailable");
|
|
452
458
|
});
|
|
459
|
+
|
|
460
|
+
it("returns '(no feedback)' when runner returns success with no output", async () => {
|
|
461
|
+
vi.mocked(runGemini).mockResolvedValueOnce({ success: true, output: undefined } as any);
|
|
462
|
+
const api = createApi();
|
|
463
|
+
const result = await runCrossModelReview(api, "gemini", "test snapshot");
|
|
464
|
+
expect(result).toBe("(no feedback)");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("passes pluginConfig to runner", async () => {
|
|
468
|
+
const api = createApi();
|
|
469
|
+
const cfg = { someKey: "someValue" };
|
|
470
|
+
await runCrossModelReview(api, "codex", "test snapshot", cfg);
|
|
471
|
+
expect(runCodex).toHaveBeenCalledWith(api, expect.any(Object), cfg);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
// initiatePlanningSession — additional branch coverage
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
describe("initiatePlanningSession — additional branches", () => {
|
|
480
|
+
it("falls back to project teams when rootIssue.team is missing", async () => {
|
|
481
|
+
const rootIssue = {
|
|
482
|
+
id: "issue-1",
|
|
483
|
+
identifier: "PROJ-1",
|
|
484
|
+
title: "Root",
|
|
485
|
+
// No team property
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const ctx = createCtx();
|
|
489
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
490
|
+
|
|
491
|
+
// Should use team from project.teams.nodes[0].id = "team-1"
|
|
492
|
+
expect(registerPlanningSession).toHaveBeenCalledWith(
|
|
493
|
+
"proj-1",
|
|
494
|
+
expect.objectContaining({
|
|
495
|
+
teamId: "team-1",
|
|
496
|
+
}),
|
|
497
|
+
undefined,
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("throws error when no team can be determined", async () => {
|
|
502
|
+
const rootIssue = {
|
|
503
|
+
id: "issue-1",
|
|
504
|
+
identifier: "PROJ-1",
|
|
505
|
+
title: "Root",
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
mockLinearApi.getProject.mockResolvedValueOnce({
|
|
509
|
+
id: "proj-no-team",
|
|
510
|
+
name: "No Team Project",
|
|
511
|
+
teams: { nodes: [] },
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const ctx = createCtx();
|
|
515
|
+
await expect(
|
|
516
|
+
initiatePlanningSession(ctx, "proj-no-team", rootIssue),
|
|
517
|
+
).rejects.toThrow("Cannot determine team");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("uses planningStatePath from pluginConfig", async () => {
|
|
521
|
+
const rootIssue = {
|
|
522
|
+
id: "issue-1",
|
|
523
|
+
identifier: "PROJ-1",
|
|
524
|
+
title: "Root",
|
|
525
|
+
team: { id: "team-1" },
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const ctx = createCtx({
|
|
529
|
+
pluginConfig: { planningStatePath: "/tmp/custom-state.json" },
|
|
530
|
+
});
|
|
531
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
532
|
+
|
|
533
|
+
expect(registerPlanningSession).toHaveBeenCalledWith(
|
|
534
|
+
"proj-1",
|
|
535
|
+
expect.any(Object),
|
|
536
|
+
"/tmp/custom-state.json",
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// handlePlannerTurn — additional branch coverage
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
describe("handlePlannerTurn — additional branches", () => {
|
|
546
|
+
const input = {
|
|
547
|
+
issueId: "issue-1",
|
|
548
|
+
commentBody: "Continue planning",
|
|
549
|
+
commentorName: "Tester",
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
it("does not post comment when agent returns no output", async () => {
|
|
553
|
+
runAgentMock.mockResolvedValueOnce({ success: true, output: "" });
|
|
554
|
+
const ctx = createCtx();
|
|
555
|
+
const session = createSession();
|
|
556
|
+
|
|
557
|
+
await handlePlannerTurn(ctx, session, input);
|
|
558
|
+
|
|
559
|
+
// createComment should NOT be called (empty output is falsy)
|
|
560
|
+
expect(mockLinearApi.createComment).not.toHaveBeenCalled();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("does not post comment when agent returns null output", async () => {
|
|
564
|
+
runAgentMock.mockResolvedValueOnce({ success: true, output: null });
|
|
565
|
+
const ctx = createCtx();
|
|
566
|
+
const session = createSession();
|
|
567
|
+
|
|
568
|
+
await handlePlannerTurn(ctx, session, input);
|
|
569
|
+
|
|
570
|
+
expect(mockLinearApi.createComment).not.toHaveBeenCalled();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("clears planner context even when agent throws", async () => {
|
|
574
|
+
runAgentMock.mockRejectedValueOnce(new Error("agent crash"));
|
|
575
|
+
const ctx = createCtx();
|
|
576
|
+
const session = createSession();
|
|
577
|
+
|
|
578
|
+
await expect(
|
|
579
|
+
handlePlannerTurn(ctx, session, input),
|
|
580
|
+
).rejects.toThrow("agent crash");
|
|
581
|
+
|
|
582
|
+
expect(clearActivePlannerContext).toHaveBeenCalled();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("uses defaultAgentId from pluginConfig", async () => {
|
|
586
|
+
const ctx = createCtx({
|
|
587
|
+
pluginConfig: { defaultAgentId: "custom-agent" },
|
|
588
|
+
});
|
|
589
|
+
const session = createSession();
|
|
590
|
+
|
|
591
|
+
await handlePlannerTurn(ctx, session, input);
|
|
592
|
+
|
|
593
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
594
|
+
expect.objectContaining({
|
|
595
|
+
agentId: "custom-agent",
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("continues even when comment history fetch fails", async () => {
|
|
601
|
+
mockLinearApi.getIssueDetails.mockRejectedValueOnce(new Error("API failure"));
|
|
602
|
+
const ctx = createCtx();
|
|
603
|
+
const session = createSession();
|
|
604
|
+
|
|
605
|
+
// Should not throw — best-effort comment history
|
|
606
|
+
await handlePlannerTurn(ctx, session, input);
|
|
607
|
+
|
|
608
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("uses planningStatePath from pluginConfig", async () => {
|
|
612
|
+
const ctx = createCtx({
|
|
613
|
+
pluginConfig: { planningStatePath: "/tmp/custom-state.json" },
|
|
614
|
+
});
|
|
615
|
+
const session = createSession();
|
|
616
|
+
|
|
617
|
+
await handlePlannerTurn(ctx, session, input);
|
|
618
|
+
|
|
619
|
+
expect(updatePlanningSession).toHaveBeenCalledWith(
|
|
620
|
+
"proj-1",
|
|
621
|
+
{ turnCount: 1 },
|
|
622
|
+
"/tmp/custom-state.json",
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// runPlanAudit — additional branch coverage
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
describe("runPlanAudit — additional branches", () => {
|
|
632
|
+
it("includes warnings in failure comment when present", async () => {
|
|
633
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
634
|
+
pass: false,
|
|
635
|
+
problems: ["Missing estimate on PROJ-3"],
|
|
636
|
+
warnings: ["PROJ-4 is an orphan issue"],
|
|
637
|
+
});
|
|
638
|
+
const ctx = createCtx();
|
|
639
|
+
const session = createSession();
|
|
640
|
+
|
|
641
|
+
await runPlanAudit(ctx, session);
|
|
642
|
+
|
|
643
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
644
|
+
"issue-1",
|
|
645
|
+
expect.stringContaining("PROJ-4 is an orphan issue"),
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("failure comment does not include warnings section when none", async () => {
|
|
650
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
651
|
+
pass: false,
|
|
652
|
+
problems: ["Missing estimate on PROJ-3"],
|
|
653
|
+
warnings: [],
|
|
654
|
+
});
|
|
655
|
+
const ctx = createCtx();
|
|
656
|
+
const session = createSession();
|
|
657
|
+
|
|
658
|
+
await runPlanAudit(ctx, session);
|
|
659
|
+
|
|
660
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
661
|
+
"issue-1",
|
|
662
|
+
expect.not.stringContaining("**Warnings:**"),
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("does not post review agent comment when output is empty", async () => {
|
|
667
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
668
|
+
runAgentMock.mockResolvedValueOnce({ success: true, output: "" });
|
|
669
|
+
const ctx = createCtx();
|
|
670
|
+
const session = createSession();
|
|
671
|
+
|
|
672
|
+
await runPlanAudit(ctx, session);
|
|
673
|
+
|
|
674
|
+
// First call is "Plan Passed Checks" message, there should be no second call for agent output
|
|
675
|
+
const commentCalls = mockLinearApi.createComment.mock.calls;
|
|
676
|
+
const agentOutputCalls = commentCalls.filter(
|
|
677
|
+
(call: any[]) => !String(call[1]).includes("Plan Passed Checks"),
|
|
678
|
+
);
|
|
679
|
+
expect(agentOutputCalls).toHaveLength(0);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("clears planner context even if review agent throws", async () => {
|
|
683
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
684
|
+
runAgentMock.mockRejectedValueOnce(new Error("agent failure"));
|
|
685
|
+
const ctx = createCtx();
|
|
686
|
+
const session = createSession();
|
|
687
|
+
|
|
688
|
+
await expect(runPlanAudit(ctx, session)).rejects.toThrow("agent failure");
|
|
689
|
+
|
|
690
|
+
expect(clearActivePlannerContext).toHaveBeenCalled();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("uses custom plannerReviewModel from pluginConfig", async () => {
|
|
694
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
695
|
+
const ctx = createCtx({
|
|
696
|
+
pluginConfig: { plannerReviewModel: "claude" },
|
|
697
|
+
});
|
|
698
|
+
const session = createSession();
|
|
699
|
+
|
|
700
|
+
await runPlanAudit(ctx, session);
|
|
701
|
+
|
|
702
|
+
expect(runClaude).toHaveBeenCalled();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("uses defaultAgentId from pluginConfig for review agent", async () => {
|
|
706
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
707
|
+
const ctx = createCtx({
|
|
708
|
+
pluginConfig: { defaultAgentId: "my-agent" },
|
|
709
|
+
});
|
|
710
|
+
const session = createSession();
|
|
711
|
+
|
|
712
|
+
await runPlanAudit(ctx, session);
|
|
713
|
+
|
|
714
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
715
|
+
expect.objectContaining({
|
|
716
|
+
agentId: "my-agent",
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
// resolveReviewModel — additional branch coverage
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
describe("resolveReviewModel — additional branches", () => {
|
|
727
|
+
it("ignores invalid plannerReviewModel and falls through", () => {
|
|
728
|
+
expect(resolveReviewModel({
|
|
729
|
+
plannerReviewModel: "invalid-model",
|
|
730
|
+
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4" } } },
|
|
731
|
+
} as any)).toBe("codex"); // falls through to primary model logic
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("returns 'gemini' for kimi model", () => {
|
|
735
|
+
expect(resolveReviewModel({
|
|
736
|
+
agents: { defaults: { model: { primary: "openrouter/moonshotai/kimi-k2.5" } } },
|
|
737
|
+
} as any)).toBe("gemini");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("returns 'gemini' for mistral model", () => {
|
|
741
|
+
expect(resolveReviewModel({
|
|
742
|
+
agents: { defaults: { model: { primary: "mistral/mistral-large" } } },
|
|
743
|
+
} as any)).toBe("gemini");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("returns 'codex' for anthropic model (without claude in name)", () => {
|
|
747
|
+
expect(resolveReviewModel({
|
|
748
|
+
agents: { defaults: { model: { primary: "anthropic/some-model" } } },
|
|
749
|
+
} as any)).toBe("codex");
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("returns 'gemini' for openai model (without codex in name)", () => {
|
|
753
|
+
expect(resolveReviewModel({
|
|
754
|
+
agents: { defaults: { model: { primary: "openai/gpt-5" } } },
|
|
755
|
+
} as any)).toBe("gemini");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("returns 'codex' for google model (without gemini in name)", () => {
|
|
759
|
+
expect(resolveReviewModel({
|
|
760
|
+
agents: { defaults: { model: { primary: "google/palm-3" } } },
|
|
761
|
+
} as any)).toBe("codex");
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("returns 'gemini' when pluginConfig is undefined", () => {
|
|
765
|
+
expect(resolveReviewModel(undefined)).toBe("gemini");
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("returns 'gemini' when agents.defaults.model is undefined", () => {
|
|
769
|
+
expect(resolveReviewModel({
|
|
770
|
+
agents: { defaults: {} },
|
|
771
|
+
} as any)).toBe("gemini");
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("respects plannerReviewModel 'codex'", () => {
|
|
775
|
+
expect(resolveReviewModel({
|
|
776
|
+
plannerReviewModel: "codex",
|
|
777
|
+
} as any)).toBe("codex");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("respects plannerReviewModel 'claude'", () => {
|
|
781
|
+
expect(resolveReviewModel({
|
|
782
|
+
plannerReviewModel: "claude",
|
|
783
|
+
} as any)).toBe("claude");
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// loadPlannerPrompts — custom YAML prompts branch
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
describe("loadPlannerPrompts — via handlePlannerTurn", () => {
|
|
792
|
+
const input = {
|
|
793
|
+
issueId: "issue-1",
|
|
794
|
+
commentBody: "Continue planning",
|
|
795
|
+
commentorName: "Tester",
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
it("uses custom planner prompts from YAML when available", async () => {
|
|
799
|
+
loadRawPromptYamlMock.mockReturnValue({
|
|
800
|
+
planner: {
|
|
801
|
+
system: "Custom system prompt",
|
|
802
|
+
interview: "Custom interview: {{userMessage}}",
|
|
803
|
+
// leave other keys undefined to test ?? fallback
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const ctx = createCtx();
|
|
808
|
+
const session = createSession();
|
|
809
|
+
|
|
810
|
+
await handlePlannerTurn(ctx, session, input);
|
|
811
|
+
|
|
812
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
813
|
+
expect.objectContaining({
|
|
814
|
+
message: expect.stringContaining("Custom system prompt"),
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("uses default welcome when YAML planner.welcome is undefined", async () => {
|
|
820
|
+
loadRawPromptYamlMock.mockReturnValue({
|
|
821
|
+
planner: {
|
|
822
|
+
welcome: "Custom welcome for **{{projectName}}**!",
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const rootIssue = {
|
|
827
|
+
id: "issue-1",
|
|
828
|
+
identifier: "PROJ-1",
|
|
829
|
+
title: "Root",
|
|
830
|
+
team: { id: "team-1" },
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const ctx = createCtx();
|
|
834
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
835
|
+
|
|
836
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
837
|
+
"issue-1",
|
|
838
|
+
expect.stringContaining("Custom welcome"),
|
|
839
|
+
);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// handlePlannerTurn — comment history with null/missing nodes
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
describe("handlePlannerTurn — comment history edge cases", () => {
|
|
848
|
+
const input = {
|
|
849
|
+
issueId: "issue-1",
|
|
850
|
+
commentBody: "Continue",
|
|
851
|
+
commentorName: "Tester",
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
it("handles null comments.nodes gracefully", async () => {
|
|
855
|
+
mockLinearApi.getIssueDetails.mockResolvedValueOnce({
|
|
856
|
+
id: "issue-1",
|
|
857
|
+
identifier: "PROJ-1",
|
|
858
|
+
title: "Root",
|
|
859
|
+
comments: { nodes: null },
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
const ctx = createCtx();
|
|
863
|
+
const session = createSession();
|
|
864
|
+
|
|
865
|
+
// Should not throw — the ?. operator handles null
|
|
866
|
+
await handlePlannerTurn(ctx, session, input);
|
|
867
|
+
|
|
868
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("handles undefined comments gracefully", async () => {
|
|
872
|
+
mockLinearApi.getIssueDetails.mockResolvedValueOnce({
|
|
873
|
+
id: "issue-1",
|
|
874
|
+
identifier: "PROJ-1",
|
|
875
|
+
title: "Root",
|
|
876
|
+
// no comments property
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const ctx = createCtx();
|
|
880
|
+
const session = createSession();
|
|
881
|
+
|
|
882
|
+
await handlePlannerTurn(ctx, session, input);
|
|
883
|
+
|
|
884
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("handles comments with missing user name", async () => {
|
|
888
|
+
mockLinearApi.getIssueDetails.mockResolvedValueOnce({
|
|
889
|
+
id: "issue-1",
|
|
890
|
+
identifier: "PROJ-1",
|
|
891
|
+
title: "Root",
|
|
892
|
+
comments: {
|
|
893
|
+
nodes: [
|
|
894
|
+
{ body: "A comment", user: null },
|
|
895
|
+
{ body: "Another comment", user: { name: "Alice" } },
|
|
896
|
+
],
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const ctx = createCtx();
|
|
901
|
+
const session = createSession();
|
|
902
|
+
|
|
903
|
+
await handlePlannerTurn(ctx, session, input);
|
|
904
|
+
|
|
905
|
+
expect(runAgentMock).toHaveBeenCalled();
|
|
906
|
+
});
|
|
453
907
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { tmpdir, homedir } from "node:os";
|
|
5
5
|
import {
|
|
6
6
|
readPlanningState,
|
|
7
7
|
writePlanningState,
|
|
@@ -234,3 +234,164 @@ describe("setPlanningCache / getActivePlanningByProjectId", () => {
|
|
|
234
234
|
expect(cleared).toBeNull();
|
|
235
235
|
});
|
|
236
236
|
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// readPlanningState — additional branch coverage
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
describe("readPlanningState — corrupted file recovery", () => {
|
|
243
|
+
it("recovers from corrupted JSON (SyntaxError)", async () => {
|
|
244
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-corrupt-"));
|
|
245
|
+
const p = join(dir, "state.json");
|
|
246
|
+
// Write invalid JSON
|
|
247
|
+
writeFileSync(p, "{ not valid json !!!", "utf-8");
|
|
248
|
+
|
|
249
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
250
|
+
const state = await readPlanningState(p);
|
|
251
|
+
|
|
252
|
+
expect(state.sessions).toEqual({});
|
|
253
|
+
expect(state.processedEvents).toEqual([]);
|
|
254
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("corrupted"));
|
|
255
|
+
consoleSpy.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("fills in missing sessions field from parsed data", async () => {
|
|
259
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-nosessions-"));
|
|
260
|
+
const p = join(dir, "state.json");
|
|
261
|
+
// Valid JSON but missing sessions
|
|
262
|
+
writeFileSync(p, JSON.stringify({ processedEvents: ["e1"] }), "utf-8");
|
|
263
|
+
|
|
264
|
+
const state = await readPlanningState(p);
|
|
265
|
+
expect(state.sessions).toEqual({});
|
|
266
|
+
expect(state.processedEvents).toEqual(["e1"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("fills in missing processedEvents field from parsed data", async () => {
|
|
270
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-noevents-"));
|
|
271
|
+
const p = join(dir, "state.json");
|
|
272
|
+
// Valid JSON but missing processedEvents
|
|
273
|
+
writeFileSync(p, JSON.stringify({ sessions: { "p1": { projectId: "p1" } } }), "utf-8");
|
|
274
|
+
|
|
275
|
+
const state = await readPlanningState(p);
|
|
276
|
+
expect(state.sessions).toBeDefined();
|
|
277
|
+
expect(state.sessions["p1"]).toBeDefined();
|
|
278
|
+
expect(state.processedEvents).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("re-throws non-ENOENT, non-SyntaxError errors", async () => {
|
|
282
|
+
// Use a path where the parent is a file (causes ENOTDIR)
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-errthrow-"));
|
|
284
|
+
const filePath = join(dir, "afile");
|
|
285
|
+
writeFileSync(filePath, "data", "utf-8");
|
|
286
|
+
// Trying to read a path that treats a file as a directory
|
|
287
|
+
const badPath = join(filePath, "subdir", "state.json");
|
|
288
|
+
|
|
289
|
+
await expect(readPlanningState(badPath)).rejects.toThrow();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// writePlanningState — truncation
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
describe("writePlanningState — processedEvents truncation", () => {
|
|
298
|
+
it("truncates processedEvents to last 200 when exceeding limit", async () => {
|
|
299
|
+
const p = tmpStatePath();
|
|
300
|
+
const events = Array.from({ length: 250 }, (_, i) => `evt-${i}`);
|
|
301
|
+
const data: PlanningState = {
|
|
302
|
+
sessions: {},
|
|
303
|
+
processedEvents: events,
|
|
304
|
+
};
|
|
305
|
+
await writePlanningState(data, p);
|
|
306
|
+
const state = await readPlanningState(p);
|
|
307
|
+
expect(state.processedEvents).toHaveLength(200);
|
|
308
|
+
// Should keep the last 200 (evt-50 through evt-249)
|
|
309
|
+
expect(state.processedEvents[0]).toBe("evt-50");
|
|
310
|
+
expect(state.processedEvents[199]).toBe("evt-249");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("does not truncate when at or below 200", async () => {
|
|
314
|
+
const p = tmpStatePath();
|
|
315
|
+
const events = Array.from({ length: 200 }, (_, i) => `evt-${i}`);
|
|
316
|
+
const data: PlanningState = {
|
|
317
|
+
sessions: {},
|
|
318
|
+
processedEvents: events,
|
|
319
|
+
};
|
|
320
|
+
await writePlanningState(data, p);
|
|
321
|
+
const state = await readPlanningState(p);
|
|
322
|
+
expect(state.processedEvents).toHaveLength(200);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// endPlanningSession — missing session branch
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
describe("endPlanningSession — additional branches", () => {
|
|
331
|
+
it("does nothing when session does not exist", async () => {
|
|
332
|
+
const p = tmpStatePath();
|
|
333
|
+
// End a session that was never registered — should not throw
|
|
334
|
+
await endPlanningSession("nonexistent-proj", "abandoned", p);
|
|
335
|
+
|
|
336
|
+
const state = await readPlanningState(p);
|
|
337
|
+
expect(state.sessions).toEqual({});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("clears planning cache even when session is missing", async () => {
|
|
341
|
+
const p = tmpStatePath();
|
|
342
|
+
const session = makePlanningSession({ projectId: "cache-test" });
|
|
343
|
+
setPlanningCache(session);
|
|
344
|
+
|
|
345
|
+
// End session that exists in cache but not in file
|
|
346
|
+
await endPlanningSession("cache-test", "abandoned", p);
|
|
347
|
+
|
|
348
|
+
// Cache should be cleared
|
|
349
|
+
expect(getActivePlanningByProjectId("cache-test")).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// getActivePlanningByProjectId — cache miss
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
describe("getActivePlanningByProjectId — additional branches", () => {
|
|
358
|
+
it("returns null for project not in cache", () => {
|
|
359
|
+
const result = getActivePlanningByProjectId("never-cached-project");
|
|
360
|
+
expect(result).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// writePlanningState — directory creation
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
describe("writePlanningState — directory creation", () => {
|
|
369
|
+
it("creates parent directory if it does not exist", async () => {
|
|
370
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-ps-newdir-"));
|
|
371
|
+
const p = join(dir, "subdir", "deep", "state.json");
|
|
372
|
+
const data: PlanningState = {
|
|
373
|
+
sessions: {},
|
|
374
|
+
processedEvents: ["e1"],
|
|
375
|
+
};
|
|
376
|
+
await writePlanningState(data, p);
|
|
377
|
+
const state = await readPlanningState(p);
|
|
378
|
+
expect(state.processedEvents).toEqual(["e1"]);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// resolveStatePath — tilde expansion
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
describe("resolveStatePath (via readPlanningState)", () => {
|
|
387
|
+
it("expands ~/... path to homedir", async () => {
|
|
388
|
+
// This exercises the `configPath.startsWith("~/")` branch.
|
|
389
|
+
// We can't easily test the exact path but we can verify it doesn't crash
|
|
390
|
+
// and resolves to a real path by using a non-existent file under ~.
|
|
391
|
+
const tildeFile = "~/nonexistent-claw-test-dir-12345/state.json";
|
|
392
|
+
// readPlanningState with ENOENT should return empty state
|
|
393
|
+
const state = await readPlanningState(tildeFile);
|
|
394
|
+
expect(state.sessions).toEqual({});
|
|
395
|
+
expect(state.processedEvents).toEqual([]);
|
|
396
|
+
});
|
|
397
|
+
});
|