mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62a66cf19c | |||
| 99c999564f | |||
| 22a7d8cdcf | |||
| 8eef095ad9 | |||
| b286c0db5f | |||
| 6ce328e17a |
@@ -1,21 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.36",
|
||||
"version": "3.0.34",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -313,45 +313,6 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "run_commands",
|
||||
input: {
|
||||
commands: [{ command: "cmd", args: ["/c", "dir"] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
const code = await runHistoryExport("sess_1", outputPath, "text", io);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(io.writeErr).not.toHaveBeenCalled();
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
|
||||
});
|
||||
|
||||
it("fails when the session artifact is missing", async () => {
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
|
||||
const io = {
|
||||
|
||||
@@ -2,14 +2,7 @@ import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
import { applyInteractiveModeConfig } from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
@@ -47,152 +40,6 @@ const switchToActModeTool = createTool({
|
||||
execute: async () => "ok",
|
||||
});
|
||||
|
||||
describe("createInteractiveModeSwitchTool", () => {
|
||||
function makeSwitchTool(config: Config) {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
} = { current: vi.fn() };
|
||||
const tool = createInteractiveModeSwitchTool({
|
||||
config,
|
||||
pendingModeChange,
|
||||
tuiModeChanged,
|
||||
});
|
||||
return { tool, pendingModeChange, tuiModeChanged };
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
agentId: "agent-1",
|
||||
iteration: 0,
|
||||
} as const;
|
||||
|
||||
it("completes the run so the model never continues with plan-mode tools", () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool } = makeSwitchTool(config);
|
||||
|
||||
// The act-mode tool set only exists after the session rebuild, which
|
||||
// happens between runs; without completesRun the model keeps working
|
||||
// with stale plan-mode tools after being told the switch succeeded.
|
||||
expect(tool.lifecycle?.completesRun).toBe(true);
|
||||
});
|
||||
|
||||
it("queues a tool-sourced mode change and notifies the TUI", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
|
||||
|
||||
const result = await tool.execute({}, toolContext);
|
||||
|
||||
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
|
||||
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
|
||||
expect(result).toContain("successfully switched to act mode");
|
||||
});
|
||||
|
||||
it("errors instead of completing the run when already in act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "act";
|
||||
const { tool, pendingModeChange } = makeSwitchTool(config);
|
||||
|
||||
// A successful result would end the run via completesRun even though
|
||||
// nothing changed, so the no-op case must surface as a tool error.
|
||||
await expect(tool.execute({}, toolContext)).rejects.toThrow(
|
||||
"Already in act mode.",
|
||||
);
|
||||
expect(pendingModeChange.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTurnWithActModeContinuation", () => {
|
||||
type TurnResult = { finishReason: string; iterations: number };
|
||||
|
||||
function makeHarness(input: {
|
||||
initial: TurnResult | undefined;
|
||||
continuation?: TurnResult | undefined;
|
||||
modeChanges: Array<AppliedModeChange | undefined>;
|
||||
}) {
|
||||
const applied = [...input.modeChanges];
|
||||
const sendContinuationTurn = vi.fn(async () => input.continuation);
|
||||
return {
|
||||
sendContinuationTurn,
|
||||
run: () =>
|
||||
sendTurnWithActModeContinuation<TurnResult>({
|
||||
sendInitialTurn: async () => input.initial,
|
||||
sendContinuationTurn,
|
||||
applyPendingModeChange: async () => applied.shift(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("continues the plan after a tool-initiated switch completes the run", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: { finishReason: "completed", iterations: 3 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).toHaveBeenCalledWith(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
|
||||
});
|
||||
|
||||
it("does not continue after a UI-initiated mode change", async () => {
|
||||
// A Tab toggle can race a natural turn completion; a "ui" source must
|
||||
// never start executing a plan the user did not approve.
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [{ mode: "act", source: "ui" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("does not continue when the switch turn was aborted", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "aborted", iterations: 1 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
|
||||
});
|
||||
|
||||
it("does not continue when no mode change was pending", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("returns the switch turn result when the continuation yields nothing", async () => {
|
||||
const { run } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: undefined,
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModeConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveSystemPrompt).mockClear();
|
||||
|
||||
@@ -2,42 +2,17 @@ import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
export type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
/**
|
||||
* Pending mode change plus who requested it. The switch_to_act_mode tool and
|
||||
* the TUI mode toggle share this slot, but only a tool-initiated switch means
|
||||
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
|
||||
* must not trigger plan execution.
|
||||
*/
|
||||
export type PendingModeChange = {
|
||||
current: InteractiveUiMode | null;
|
||||
source: "tool" | "ui" | null;
|
||||
};
|
||||
|
||||
export type AppliedModeChange = {
|
||||
mode: InteractiveUiMode;
|
||||
source: "tool" | "ui";
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned prompt that drives the auto-continue turn after the model calls
|
||||
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
|
||||
* filters it out of the chat display.
|
||||
*/
|
||||
export const ACT_MODE_CONTINUATION_PROMPT =
|
||||
"The user approved switching to act mode. Continue with the approved plan now.";
|
||||
type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: PendingModeChange;
|
||||
pendingModeChange: { current: InteractiveUiMode | null };
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
"Switch from plan mode to act mode. Call this after the user has confirmed they want to proceed with the plan. Do not call this proactively or before the user has agreed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -45,68 +20,17 @@ export function createInteractiveModeSwitchTool(input: {
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// The act-mode tools only exist after the session is rebuilt with the
|
||||
// new mode config, which can't happen mid-run. End the run right after
|
||||
// the tool result so the model never keeps working with plan-mode tools
|
||||
// it was just told it no longer has; run-interactive applies the pending
|
||||
// change and auto-continues on the rebuilt session.
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
if (input.config.mode === "act") {
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
return "Already in act mode.";
|
||||
}
|
||||
input.pendingModeChange.current = "act";
|
||||
input.pendingModeChange.source = "tool";
|
||||
input.tuiModeChanged.current?.("act");
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one interactive turn, and when the model ended it by calling
|
||||
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
|
||||
* session instead of waiting for the user to prompt again.
|
||||
*
|
||||
* The continuation only fires for a tool-initiated switch on a turn that
|
||||
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
|
||||
* toggle races a natural completion its source is "ui", so the user's Tab
|
||||
* press can never start executing a plan they did not approve.
|
||||
*/
|
||||
export async function sendTurnWithActModeContinuation<
|
||||
T extends { finishReason: string; iterations: number },
|
||||
>(input: {
|
||||
sendInitialTurn: () => Promise<T | undefined>;
|
||||
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
|
||||
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
|
||||
}): Promise<T | undefined> {
|
||||
const result = await input.sendInitialTurn();
|
||||
const switched = await input.applyPendingModeChange();
|
||||
if (
|
||||
switched?.mode !== "act" ||
|
||||
switched.source !== "tool" ||
|
||||
result?.finishReason !== "completed"
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const continuation = await input.sendContinuationTurn(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
// Honor a mode toggle made while the continuation was running.
|
||||
await input.applyPendingModeChange();
|
||||
if (!continuation) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...continuation,
|
||||
iterations: result.iterations + continuation.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
|
||||
@@ -20,7 +20,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
|
||||
When the user aligns on a plan and is ready to proceed, use the switch_to_act_mode tool to switch to act mode and begin implementation.`;
|
||||
|
||||
export async function resolveSystemPrompt(input: {
|
||||
cwd: string;
|
||||
|
||||
@@ -52,12 +52,7 @@ import {
|
||||
type InteractiveExitSummary,
|
||||
} from "./interactive/exit-summary";
|
||||
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
|
||||
import {
|
||||
type AppliedModeChange,
|
||||
createInteractiveModeSwitchTool,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./interactive/mode";
|
||||
import { createInteractiveModeSwitchTool } from "./interactive/mode";
|
||||
import { assertInteractivePreflight } from "./interactive/preflight";
|
||||
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
@@ -154,9 +149,8 @@ export async function runInteractive(
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
const pendingModeChange: { current: "plan" | "act" | null } = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
@@ -527,36 +521,26 @@ export async function runInteractive(
|
||||
...userImages,
|
||||
];
|
||||
|
||||
const applyPendingModeChange = async (): Promise<
|
||||
AppliedModeChange | undefined
|
||||
> => {
|
||||
const applyPendingModeChange = async () => {
|
||||
if (!pendingModeChange.current) return undefined;
|
||||
const applied: AppliedModeChange = {
|
||||
mode: pendingModeChange.current,
|
||||
source: pendingModeChange.source ?? "ui",
|
||||
};
|
||||
const newMode = pendingModeChange.current;
|
||||
pendingModeChange.current = null;
|
||||
pendingModeChange.source = null;
|
||||
await sessionRuntime.applyMode(applied.mode);
|
||||
tuiModeChanged.current?.(applied.mode);
|
||||
return applied;
|
||||
await sessionRuntime.applyMode(newMode);
|
||||
tuiModeChanged.current?.(newMode);
|
||||
return newMode;
|
||||
};
|
||||
|
||||
const result = await sendTurnWithActModeContinuation({
|
||||
sendInitialTurn: () =>
|
||||
sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
}),
|
||||
sendContinuationTurn: (prompt) =>
|
||||
sessionRuntime.sendCurrentTurn({ prompt, mode: "act" }),
|
||||
applyPendingModeChange,
|
||||
const result = await sessionRuntime.sendCurrentTurn({
|
||||
prompt: userInput,
|
||||
mode,
|
||||
userImages:
|
||||
mergedUserImages.length > 0 ? mergedUserImages : undefined,
|
||||
userFiles: userFiles.length > 0 ? userFiles : undefined,
|
||||
delivery,
|
||||
});
|
||||
|
||||
await applyPendingModeChange();
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
@@ -658,7 +642,6 @@ export async function runInteractive(
|
||||
if (!isInteractiveMode(mode)) return;
|
||||
if (isRunning) {
|
||||
pendingModeChange.current = mode;
|
||||
pendingModeChange.source = "ui";
|
||||
sessionRuntime.abortAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ToolResultContent,
|
||||
type ToolUseContent,
|
||||
} from "@cline/shared";
|
||||
import { formatStructuredCommand } from "../utils/helpers";
|
||||
|
||||
export interface ConversationHistory {
|
||||
version: number;
|
||||
@@ -846,15 +845,15 @@ function renderDiffHTML(
|
||||
}
|
||||
|
||||
function renderCommandsHTML(
|
||||
commands: unknown[],
|
||||
commands: string[],
|
||||
_result?: ToolResultContent,
|
||||
): string {
|
||||
return commands
|
||||
.map(
|
||||
(command, i) => `
|
||||
(cmd, i) => `
|
||||
<div class="command-block">
|
||||
<div class="command-label">Command ${i + 1}</div>
|
||||
<code>${escapeHtml(formatStructuredCommand(command))}</code>
|
||||
<code>${escapeHtml(cmd)}</code>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -128,8 +128,7 @@ export async function createClineAccountService(input: {
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
@@ -11,6 +9,5 @@ export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
|
||||
describe("hydrateSessionMessages", () => {
|
||||
it("renders regular user messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the synthetic act-mode continuation prompt", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "On it.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { formatDisplayUserInput, type Message } from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
@@ -12,12 +11,6 @@ function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
// The act-mode continuation prompt is runtime-generated, not typed by the
|
||||
// user, so it should not surface as a user bubble in the transcript.
|
||||
function isSyntheticUserText(text: string): boolean {
|
||||
return text === ACT_MODE_CONTINUATION_PROMPT;
|
||||
}
|
||||
|
||||
function stringifyToolResult(
|
||||
content: string | Array<{ type: string; text?: string; path?: string }>,
|
||||
): string {
|
||||
@@ -47,9 +40,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.role === "user") {
|
||||
const text = formatDisplayUserInput(msg.content);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
if (text) entries.push({ kind: "user_submitted", text });
|
||||
} else {
|
||||
entries.push({
|
||||
kind: "assistant_text",
|
||||
@@ -124,7 +115,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (msg.role === "user" && userTextParts.length > 0) {
|
||||
const combined = userTextParts.join("\n");
|
||||
const text = formatDisplayUserInput(combined);
|
||||
if (text && !isSyntheticUserText(text)) {
|
||||
if (text) {
|
||||
entries.push({ kind: "user_submitted", text });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function truncate(str: string, maxLen: number): string {
|
||||
return `${oneLine.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
|
||||
export function formatStructuredCommand(cmd: unknown): string {
|
||||
function formatStructuredCommand(cmd: unknown): string {
|
||||
if (typeof cmd === "string") {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -77,17 +77,12 @@ function getOwnServerRecord(
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(
|
||||
mutate: (servers: Record<string, unknown>) => void,
|
||||
): void {
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
@@ -103,9 +98,7 @@ export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
@@ -133,9 +126,7 @@ export function clearServerOAuth(name: string): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
|
||||
@@ -85,8 +85,7 @@ export function setMcpServerDisabled(
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
@@ -129,8 +128,7 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -145,8 +143,7 @@ export function deleteMcpServer(name: string): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll inspect the file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "src/index.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "result-block-1",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "I'll inspect the file.",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: { path: "src/index.ts" },
|
||||
output: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "I'll inspect the file.",
|
||||
},
|
||||
{
|
||||
id: "assistant-1:tool:toolu_1",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
output: "export const value = 1;",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates error tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "missing.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "File not found",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-error",
|
||||
output: "File not found",
|
||||
error: "File not found",
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "toolu_1",
|
||||
state: "output-error",
|
||||
error: "File not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates orphan tool results as standalone meta tool blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_orphan",
|
||||
name: "read_file",
|
||||
content: "orphan output",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "meta",
|
||||
text: "",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_orphan",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "user-1:tool:toolu_orphan",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_orphan",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates plain string content as a text block", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Plain response",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "Plain response",
|
||||
reasoning: undefined,
|
||||
reasoningRedacted: undefined,
|
||||
toolEvents: undefined,
|
||||
blocks: [
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "Plain response",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates same-message tool-call and tool-result blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
input: { query: "cline" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "call_1",
|
||||
name: "search",
|
||||
state: "output-available",
|
||||
input: { query: "cline" },
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks).toHaveLength(1);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "call_1",
|
||||
state: "output-available",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import type { HubContext } from "./state";
|
||||
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
asTimestamp,
|
||||
basename,
|
||||
@@ -89,291 +88,27 @@ function summarizeClient(client: TrackedClient): {
|
||||
};
|
||||
}
|
||||
|
||||
type HistoryToolLocation = {
|
||||
messageIndex: number;
|
||||
blockIndex: number;
|
||||
};
|
||||
|
||||
function historyContentParts(content: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => asRecord(part))
|
||||
.filter((part): part is Record<string, unknown> => Boolean(part));
|
||||
}
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function blockType(block: Record<string, unknown>): string {
|
||||
return asString(block.type)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function toolCallIdForCall(block: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
asString(block.id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolCallIdForResult(
|
||||
block: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
asString(block.tool_use_id) ??
|
||||
asString(block.toolCallId) ??
|
||||
asString(block.tool_call_id)
|
||||
);
|
||||
}
|
||||
|
||||
function toolNameFor(block: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(block.name) ??
|
||||
asString(block.toolName) ??
|
||||
asString(block.tool_name) ??
|
||||
"tool"
|
||||
);
|
||||
}
|
||||
|
||||
function toolInputFor(block: Record<string, unknown>): unknown {
|
||||
return block.input ?? block.args ?? block.arguments;
|
||||
}
|
||||
|
||||
function toolOutputFor(block: Record<string, unknown>): unknown {
|
||||
return block.output ?? block.result ?? block.content;
|
||||
}
|
||||
|
||||
function isErrorToolResult(block: Record<string, unknown>): boolean {
|
||||
return (
|
||||
block.is_error === true || block.isError === true || block.error === true
|
||||
);
|
||||
}
|
||||
|
||||
function pushTextBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
textParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (!text) return;
|
||||
textParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:text:${partIndex}`,
|
||||
type: "text",
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
function pushReasoningBlock(
|
||||
blocks: NonNullable<WebviewChatMessage["blocks"]>,
|
||||
reasoningParts: string[],
|
||||
messageKey: string | number,
|
||||
partIndex: number,
|
||||
text: string,
|
||||
redacted?: boolean,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
reasoningParts.push(text);
|
||||
blocks.push({
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text,
|
||||
redacted,
|
||||
});
|
||||
return redacted === true;
|
||||
}
|
||||
|
||||
export function mapHistoryToWebviewMessages(
|
||||
history: unknown[],
|
||||
): WebviewChatMessage[] {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
return history.map((entry, index) => {
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
let role: WebviewChatMessage["role"] =
|
||||
const role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
? rawRole
|
||||
: "meta";
|
||||
const blocks: NonNullable<WebviewChatMessage["blocks"]> = [];
|
||||
const textParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const toolEvents = new Map<
|
||||
string,
|
||||
NonNullable<WebviewChatMessage["toolEvents"]>[number]
|
||||
>();
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
let reasoningRedacted = false;
|
||||
|
||||
const contentParts = historyContentParts(record.content);
|
||||
if (contentParts.length === 0) {
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
pushTextBlock(blocks, textParts, messageKey, 0, text);
|
||||
}
|
||||
|
||||
for (const [partIndex, part] of contentParts.entries()) {
|
||||
const type = blockType(part);
|
||||
if (type === "text") {
|
||||
pushTextBlock(
|
||||
blocks,
|
||||
textParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.text) ?? asString(part.content) ?? "",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
asString(part.thinking) ??
|
||||
asString(part.reasoning) ??
|
||||
asString(part.text) ??
|
||||
"",
|
||||
part.redacted === true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "redacted_thinking") {
|
||||
reasoningRedacted =
|
||||
pushReasoningBlock(
|
||||
blocks,
|
||||
reasoningParts,
|
||||
messageKey,
|
||||
partIndex,
|
||||
"[redacted]",
|
||||
true,
|
||||
) || reasoningRedacted;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use" || type === "tool-call") {
|
||||
const toolCallId =
|
||||
toolCallIdForCall(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const toolEvent = {
|
||||
id: `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name,
|
||||
text: `Running ${name}...`,
|
||||
state: "input-available" as const,
|
||||
input: toolInputFor(part),
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
|
||||
toolLocations.set(toolCallId, {
|
||||
messageIndex: mapped.length,
|
||||
blockIndex: blocks.length - 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool-result") {
|
||||
const toolCallId =
|
||||
toolCallIdForResult(part) ?? `${messageKey}:${partIndex}`;
|
||||
const name = toolNameFor(part);
|
||||
const output = toolOutputFor(part);
|
||||
const isError = isErrorToolResult(part);
|
||||
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
|
||||
const existingLocation = toolLocations.get(toolCallId);
|
||||
const existing =
|
||||
currentBlockIndex !== undefined
|
||||
? blocks[currentBlockIndex]
|
||||
: existingLocation !== undefined
|
||||
? mapped[existingLocation.messageIndex]?.blocks?.[
|
||||
existingLocation.blockIndex
|
||||
]
|
||||
: undefined;
|
||||
const existingToolEvent =
|
||||
existing?.type === "tool" ? existing.toolEvent : undefined;
|
||||
const toolEvent = {
|
||||
id: existingToolEvent?.id ?? `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: existingToolEvent?.name ?? name,
|
||||
text: isError
|
||||
? `${existingToolEvent?.name ?? name} failed`
|
||||
: `${existingToolEvent?.name ?? name} completed`,
|
||||
state: isError
|
||||
? ("output-error" as const)
|
||||
: ("output-available" as const),
|
||||
input: existingToolEvent?.input,
|
||||
output,
|
||||
error: isError ? stringifyContent(output) : undefined,
|
||||
};
|
||||
|
||||
if (currentBlockIndex !== undefined && existing?.type === "tool") {
|
||||
blocks[currentBlockIndex] = {
|
||||
...existing,
|
||||
toolEvent,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
} else if (
|
||||
existingLocation !== undefined &&
|
||||
existing?.type === "tool"
|
||||
) {
|
||||
const target = mapped[existingLocation.messageIndex];
|
||||
const targetBlocks = target.blocks;
|
||||
const targetBlock = targetBlocks?.[existingLocation.blockIndex];
|
||||
if (targetBlocks && targetBlock?.type === "tool") {
|
||||
targetBlocks[existingLocation.blockIndex] = {
|
||||
...targetBlock,
|
||||
toolEvent,
|
||||
};
|
||||
}
|
||||
target.toolEvents = (target.toolEvents ?? []).map((event) =>
|
||||
event.toolCallId === toolCallId ? toolEvent : event,
|
||||
);
|
||||
} else {
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join("\n");
|
||||
const toolEventList = [...toolEvents.values()];
|
||||
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!text && role === "user" && toolEventList.length > 0) {
|
||||
role = "meta";
|
||||
}
|
||||
mapped.push({
|
||||
id: messageKey,
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
return {
|
||||
id: asString(record.id) ?? `history-${index}`,
|
||||
role,
|
||||
text,
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function trackSession(record: unknown): TrackedSession | undefined {
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PageFrame({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ProviderListContent({
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Model Providers
|
||||
Models
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
@@ -1038,8 +1043,7 @@ export async function handleCommand(
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
@@ -1090,8 +1094,7 @@ export async function handleCommand(
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
@@ -1103,8 +1106,7 @@ export async function handleCommand(
|
||||
if (command === "delete_mcp_server") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
|
||||
delete servers[String(args?.name ?? "")];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
|
||||
@@ -284,10 +284,6 @@ message Settings {
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
optional bool show_feature_tips = 182;
|
||||
optional string plan_mode_cline_pass_model_id = 183;
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 184;
|
||||
optional string act_mode_cline_pass_model_id = 185;
|
||||
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 186;
|
||||
}
|
||||
|
||||
message State {
|
||||
@@ -429,7 +425,6 @@ message UpdateSettingsRequest {
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional string compaction_strategy = 44;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -237,16 +237,6 @@ message ShowWebviewEvent {
|
||||
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
|
||||
}
|
||||
|
||||
message IntentEvent {
|
||||
string action = 1;
|
||||
string source = 2;
|
||||
bool has_text = 3;
|
||||
bool has_images = 4;
|
||||
bool has_files = 5;
|
||||
bool has_active_task = 6;
|
||||
int32 text_length = 7;
|
||||
}
|
||||
|
||||
// UiService provides methods for managing UI interactions
|
||||
service UiService {
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
@@ -302,7 +292,4 @@ service UiService {
|
||||
|
||||
// Opens the Cline walkthrough
|
||||
rpc openWalkthrough(EmptyRequest) returns (Empty);
|
||||
|
||||
// Tracks intent signals before task creation or first model activity
|
||||
rpc trackIntent(IntentEvent) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -14,66 +14,18 @@ import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const isWindows = process.platform === "win32"
|
||||
// Resolve the grpc-tools package root via its package.json (stable regardless of `main`), so we
|
||||
// can both locate the bundled protoc and re-run its install script when the binary is missing.
|
||||
const GRPC_TOOLS_DIR = path.dirname(require.resolve("grpc-tools/package.json"))
|
||||
const GRPC_TOOLS_PROTOC = path.join(GRPC_TOOLS_DIR, "bin", isWindows ? "protoc.exe" : "protoc")
|
||||
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
|
||||
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
|
||||
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
|
||||
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
|
||||
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
|
||||
|
||||
// `bun install` skips grpc-tools' `install` lifecycle script (`node-pre-gyp install`), so the prebuilt
|
||||
// protoc is never downloaded into bin/. When it's missing, run that same command here to fetch it.
|
||||
// grpc-tools depends on @mapbox/node-pre-gyp, which exposes the `node-pre-gyp` CLI.
|
||||
function resolveNodePreGypCli() {
|
||||
const candidates = ["@mapbox/node-pre-gyp/bin/node-pre-gyp", "node-pre-gyp/bin/node-pre-gyp"]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
// Resolve from the grpc-tools package (its direct dependency).
|
||||
return require.resolve(candidate, { paths: [GRPC_TOOLS_DIR] })
|
||||
} catch {
|
||||
// Fall back to resolving from this script's location (covers hoisted installs).
|
||||
try {
|
||||
return require.resolve(candidate)
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ensureProtocBinary() {
|
||||
console.warn(chalk.yellow(`protoc not found at ${GRPC_TOOLS_PROTOC}; downloading the grpc-tools prebuilt binary...`))
|
||||
const nodePreGypCli = resolveNodePreGypCli()
|
||||
if (!nodePreGypCli) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Could not resolve the node-pre-gyp CLI from ${GRPC_TOOLS_DIR}. Run \`bun install\`, then retry \`bun run protos\`.`,
|
||||
),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
try {
|
||||
// Mirrors grpc-tools' `scripts.install` ("node-pre-gyp install"): downloads the prebuilt
|
||||
// protoc for the current platform/arch into grpc-tools/bin.
|
||||
execFileSync(process.execPath, [nodePreGypCli, "install"], { cwd: GRPC_TOOLS_DIR, stdio: "inherit" })
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Failed to download protoc via node-pre-gyp: ${error?.message ?? error}`))
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fsSync.existsSync(GRPC_TOOLS_PROTOC)) {
|
||||
console.error(chalk.red(`protoc still not found at ${GRPC_TOOLS_PROTOC} after node-pre-gyp install.`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(chalk.green("✓ protoc binary installed."))
|
||||
}
|
||||
|
||||
if (!fsSync.existsSync(PROTOC)) {
|
||||
// PROTOC only differs from GRPC_TOOLS_PROTOC when the legacy Windows path exists, so a missing
|
||||
// PROTOC always means the grpc-tools-bundled protoc needs to be fetched.
|
||||
ensureProtocBinary()
|
||||
const windowsHint = isWindows
|
||||
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
|
||||
: ""
|
||||
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PROTO_DIR = path.resolve("proto")
|
||||
|
||||
@@ -85,7 +85,7 @@ function inferProtoType(typeText, fieldName) {
|
||||
["FocusChainSettings", "FocusChainSettings"],
|
||||
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
|
||||
["PlanActMode", "PlanActMode"],
|
||||
["ApiProvider", "string"],
|
||||
["ApiProvider", "ApiProvider"],
|
||||
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// This allows the SdkController to reuse the classic state-building logic
|
||||
// without inheriting the entire classic Controller implementation.
|
||||
|
||||
import { readCompactionStrategyGlobally } from "@cline/core"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { ClineEnv } from "@/config"
|
||||
@@ -41,7 +40,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
const mode = stateManager.getGlobalSettingsKey("mode")
|
||||
const yoloModeToggled = stateManager.getGlobalSettingsKey("yoloModeToggled")
|
||||
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const userInfo = stateManager.getGlobalStateKey("userInfo")
|
||||
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
|
||||
@@ -120,7 +118,6 @@ export async function getStateToPostToWebview(controller: {
|
||||
mode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
|
||||
@@ -58,9 +58,9 @@ describe("updateAutoApprovalSettings", () => {
|
||||
},
|
||||
}
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls).toEqual([["autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls).toEqual([["task-1", "autoApprovalSettings", expectedSettings]])
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
expect(controller.stateManager.setGlobalState).toHaveBeenCalledWith("autoApprovalSettings", expectedSettings)
|
||||
expect(controller.stateManager.setTaskSettings).toHaveBeenCalledWith("task-1", "autoApprovalSettings", expectedSettings)
|
||||
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("does not create a task override when no task is active", async () => {
|
||||
@@ -76,9 +76,9 @@ describe("updateAutoApprovalSettings", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(1)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(1)
|
||||
expect(controller.stateManager.setGlobalState).toHaveBeenCalledOnce()
|
||||
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
|
||||
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("ignores stale auto-approval settings versions", async () => {
|
||||
@@ -100,8 +100,8 @@ describe("updateAutoApprovalSettings", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(controller.stateManager.setGlobalState.mock.calls.length).toBe(0)
|
||||
expect(controller.stateManager.setTaskSettings.mock.calls.length).toBe(0)
|
||||
expect(controller.postStateToWebview.mock.calls.length).toBe(0)
|
||||
expect(controller.stateManager.setGlobalState).not.toHaveBeenCalled()
|
||||
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
|
||||
expect(controller.postStateToWebview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { setCompactionStrategyGlobally } from "@cline/core"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -180,14 +179,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
|
||||
}
|
||||
|
||||
if (request.compactionStrategy !== undefined) {
|
||||
const strategy = request.compactionStrategy
|
||||
if (strategy !== "basic" && strategy !== "agentic") {
|
||||
throw new Error(`Invalid compaction strategy value: ${strategy}`)
|
||||
}
|
||||
setCompactionStrategyGlobally(strategy)
|
||||
}
|
||||
|
||||
// Update custom prompt choice
|
||||
if (request.customPrompt !== undefined) {
|
||||
const value = request.customPrompt === "compact" ? "compact" : undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
import type { Settings } from "@shared/storage/state-keys"
|
||||
import { Settings } from "@shared/storage/state-keys"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { ClineEnv } from "@/config"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import type { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
export async function trackIntent(_controller: Controller, request: IntentEvent): Promise<Empty> {
|
||||
switch (request.action) {
|
||||
case "new_task_clicked":
|
||||
telemetryService.captureNewTaskClicked(request.source, request.hasActiveTask)
|
||||
break
|
||||
case "prompt_submitted":
|
||||
telemetryService.capturePromptSubmitted({
|
||||
source: request.source,
|
||||
hasText: request.hasText,
|
||||
hasImages: request.hasImages,
|
||||
hasFiles: request.hasFiles,
|
||||
hasActiveTask: request.hasActiveTask,
|
||||
textLength: request.textLength,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return Empty.create({})
|
||||
}
|
||||
@@ -123,7 +123,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.PlusButton, async () => {
|
||||
const sidebarInstance = WebviewProvider.getInstance()
|
||||
telemetryService.captureNewTaskClicked("activity_bar_plus", !!sidebarInstance.controller.task)
|
||||
await sidebarInstance.controller.clearTask()
|
||||
await sidebarInstance.controller.postStateToWebview()
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as vscode from "vscode"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WebviewMessage } from "@/shared/WebviewMessage"
|
||||
@@ -68,7 +67,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
// Sets up an event listener to listen for messages passed from the webview view context
|
||||
// and executes code based on the message that is received
|
||||
this.setWebviewMessageListener(webviewView.webview)
|
||||
telemetryService.capturePanelOpened("sidebar_resolved")
|
||||
|
||||
// Logs show up in bottom panel > Debug Console
|
||||
//Logger.log("registering listener")
|
||||
@@ -82,7 +80,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
webviewView.onDidChangeVisibility(
|
||||
async () => {
|
||||
if (this.webview?.visible) {
|
||||
telemetryService.capturePanelOpened("sidebar_visible")
|
||||
// View becoming visible should not steal editor focus.
|
||||
await sendShowWebviewEvent(true)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { resolveWorkspaceRootPath } from "./workspace-root"
|
||||
|
||||
describe("isClineProvider", () => {
|
||||
it("treats both Cline account providers as Cline providers", () => {
|
||||
expect(isClineProvider("cline")).toBe(true)
|
||||
expect(isClineProvider("cline-pass")).toBe(true)
|
||||
expect(isClineProvider("anthropic")).toBe(false)
|
||||
expect(isClineProvider(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWorkspaceRootPath", () => {
|
||||
it("uses the first non-empty workspace path when available", () => {
|
||||
expect(resolveWorkspaceRootPath(["", "/workspace"], "/Users/tester/Desktop")).toBe("/workspace")
|
||||
|
||||
@@ -44,7 +44,6 @@ import { telemetryService } from "@/services/telemetry"
|
||||
import type { ClineExtensionContext } from "@/shared/cline"
|
||||
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineProvider } from "@/shared/utils/cline"
|
||||
import { arePathsEqual, getDesktopDir } from "@/utils/path"
|
||||
import { ClineAccountService } from "./account-service"
|
||||
import { AuthService, LogoutReason } from "./auth-service"
|
||||
@@ -54,12 +53,6 @@ import { createProviderCatalog } from "./model-catalog/catalog"
|
||||
import type { Disposable, ProviderCatalog, ProviderConfigChange, ProviderConfigStore } from "./model-catalog/contracts"
|
||||
import { parseProviderId } from "./model-catalog/provider-id"
|
||||
import { createProviderConfigStore } from "./model-catalog/store"
|
||||
import {
|
||||
PROVIDER_FAILURE_ERROR_TYPE,
|
||||
PROVIDER_FAILURE_PHASE,
|
||||
type ProviderFailureTelemetry,
|
||||
ProviderFailureTelemetryTurnGate,
|
||||
} from "./provider-failure-telemetry"
|
||||
import {
|
||||
findVisibleCheckpointUserMessageByRun,
|
||||
getCheckpointRunCountForMessage,
|
||||
@@ -166,7 +159,6 @@ export class Controller {
|
||||
private sessionEvents: SdkSessionEventCoordinator
|
||||
private sessionHistory: SdkSessionHistoryLoader
|
||||
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
|
||||
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
|
||||
private readonly providerConfigStore: ProviderConfigStore
|
||||
private readonly providerCatalog: ProviderCatalog
|
||||
private readonly providerConfigStoreSubscription: Disposable
|
||||
@@ -310,9 +302,6 @@ export class Controller {
|
||||
}
|
||||
return this._terminalManager
|
||||
},
|
||||
onSendStart: () => {
|
||||
this.beginProviderFailureTelemetryTurn()
|
||||
},
|
||||
onSendComplete: async () => {
|
||||
await this.providerChanges.handleTurnComplete(this.mode)
|
||||
|
||||
@@ -324,39 +313,17 @@ export class Controller {
|
||||
// A turn failed — the UI shows error recovery (Retry / Sign In / Add Credits).
|
||||
this.turnStateTracker.set("error")
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const providerId = this.getSessionProviderId(sessionId) ?? this.getActiveProviderId()
|
||||
const isClineAuthError =
|
||||
isClineProvider(providerId) &&
|
||||
this.isClineProviderActive() &&
|
||||
(errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
|
||||
errorMessage.toLowerCase().includes("missing api key") ||
|
||||
errorMessage.toLowerCase().includes("unauthorized"))
|
||||
|
||||
if (isClineAuthError) {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.emitClineAuthError()
|
||||
} else if (isClineProvider(providerId) && this.isClineBalanceError(errorMessage)) {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.BALANCE,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
} else if (this.isClineProviderActive() && this.isClineBalanceError(errorMessage)) {
|
||||
this.emitClineBalanceError(errorMessage)
|
||||
} else {
|
||||
this.captureProviderFailure({
|
||||
sessionId,
|
||||
error,
|
||||
providerId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SEND_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
this.messages.emitSessionEvents(
|
||||
[
|
||||
{
|
||||
@@ -393,7 +360,7 @@ export class Controller {
|
||||
loadInitialMessages: async (sdkHost, sessionId) =>
|
||||
(await this.sessionHistory.loadInitialMessages(sdkHost, sessionId)) ?? [],
|
||||
buildStartSessionInput,
|
||||
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
getTurnPhase: () => this.turnStateTracker.currentPhase,
|
||||
@@ -444,7 +411,7 @@ export class Controller {
|
||||
buildStartSessionInput,
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
|
||||
emitClineAuthError: () => this.emitClineAuthError(),
|
||||
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
onResumeFailed: () => {
|
||||
@@ -493,8 +460,7 @@ export class Controller {
|
||||
loadInitialMessages: (reader, taskId) => this.sessionHistory.loadInitialMessages(reader, taskId),
|
||||
resolveContextMentions: (text) => this.resolveContextMentions(text),
|
||||
isClineProviderActive: () => this.isClineProviderActive(),
|
||||
emitClineAuthError: (task) => this.emitClineAuthErrorWithTelemetry(task),
|
||||
captureProviderApiError: (event) => this.captureProviderFailure(event),
|
||||
emitClineAuthError: (task) => this.emitClineAuthError(task),
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
})
|
||||
this.compaction = new SdkCompactionCoordinator({
|
||||
@@ -520,8 +486,6 @@ export class Controller {
|
||||
getTask: () => this.task,
|
||||
postStateToWebview: () => this.postStateToWebview(),
|
||||
setTurnPhase: (phase, anchorTs) => this.turnStateTracker.set(phase, anchorTs),
|
||||
captureProviderApiError: (event) => this.captureProviderFailure(event),
|
||||
beginProviderFailureTelemetryTurn: () => this.beginProviderFailureTelemetryTurn(),
|
||||
})
|
||||
// Subscribe to MCP tool list changes so we can restart the SDK session
|
||||
// when servers are added/removed/reconnected. The SDK's DefaultSessionBuilder
|
||||
@@ -854,78 +818,11 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private getTaskModelId(): string | undefined {
|
||||
const modelId = this.task?.api?.getModel?.().id?.trim()
|
||||
return modelId && modelId !== "unknown" ? modelId : undefined
|
||||
}
|
||||
|
||||
private getSessionProviderId(sessionId?: string): string | undefined {
|
||||
const activeSession = this.sessions.getActiveSession()
|
||||
if (sessionId && activeSession?.sessionId !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const providerId =
|
||||
activeSession?.startResult?.manifest?.provider?.trim() || activeSession?.startConfig?.providerId?.trim()
|
||||
return providerId && providerId !== "unknown" ? providerId : undefined
|
||||
}
|
||||
|
||||
private getSessionModelId(sessionId?: string): string | undefined {
|
||||
const activeSession = this.sessions.getActiveSession()
|
||||
if (sessionId && activeSession?.sessionId !== sessionId) {
|
||||
return undefined
|
||||
}
|
||||
const modelId = activeSession?.startResult?.manifest?.model?.trim() || activeSession?.startConfig?.modelId?.trim()
|
||||
return modelId && modelId !== "unknown" ? modelId : undefined
|
||||
}
|
||||
|
||||
private beginProviderFailureTelemetryTurn(): void {
|
||||
this.providerFailureTelemetryTurnGate.beginTurn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the active API provider uses Cline account auth for the current mode.
|
||||
* Check if the active API provider is 'cline' (for current mode).
|
||||
*/
|
||||
private isClineProviderActive(): boolean {
|
||||
return isClineProvider(this.getActiveProviderId())
|
||||
}
|
||||
|
||||
private captureProviderFailure(event: ProviderFailureTelemetry): void {
|
||||
const ulid = event.sessionId ?? this.task?.taskId ?? this.sessions.getActiveSession()?.sessionId
|
||||
if (!ulid) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.failurePhase === PROVIDER_FAILURE_PHASE.STREAMING &&
|
||||
!this.providerFailureTelemetryTurnGate.shouldCaptureStreamingFailure()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = event.providerId ?? this.getSessionProviderId(event.sessionId) ?? "unknown"
|
||||
const model = event.modelId ?? this.getSessionModelId(event.sessionId) ?? this.getTaskModelId() ?? "unknown"
|
||||
const clineError = ClineError.transform(event.error, model, provider)
|
||||
|
||||
telemetryService.captureProviderApiError({
|
||||
ulid,
|
||||
model,
|
||||
provider,
|
||||
errorMessage: clineError.message || String(event.error),
|
||||
errorStatus: clineError.status,
|
||||
requestId: clineError.requestId,
|
||||
errorType: event.errorType,
|
||||
failurePhase: event.failurePhase,
|
||||
})
|
||||
}
|
||||
|
||||
private emitClineAuthErrorWithTelemetry(task?: string, sessionId?: string): void {
|
||||
this.emitClineAuthError(task)
|
||||
this.captureProviderFailure({
|
||||
sessionId: sessionId ?? this.task?.taskId,
|
||||
error: CLINE_ACCOUNT_AUTH_ERROR_MESSAGE,
|
||||
providerId: this.getActiveProviderId(),
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.AUTH,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
return this.getActiveProviderId() === "cline"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1243,7 +1140,7 @@ export class Controller {
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
|
||||
const config = await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle })
|
||||
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
|
||||
this.emitClineAuthErrorWithTelemetry(editedText)
|
||||
this.emitClineAuthError(editedText)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1345,7 +1242,7 @@ export class Controller {
|
||||
const historyTitle = checkpointRunCount === 1 ? restoredText : firstUserMessage?.text || restoredText
|
||||
const config = restoreMessages ? await this.sessionConfigBuilder.build({ cwd, mode, prompt: historyTitle }) : undefined
|
||||
if (config && usesClineAccountAuth(config.providerId) && !config.apiKey) {
|
||||
this.emitClineAuthErrorWithTelemetry(restoredText)
|
||||
this.emitClineAuthError(restoredText)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +71,9 @@ vi.mock("@shared/services/Logger", () => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tempDir: string
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
|
||||
vi.clearAllMocks()
|
||||
mocks.stateManager.getApiConfiguration.mockReturnValue({
|
||||
actModeApiProvider: "anthropic",
|
||||
@@ -93,7 +91,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -381,7 +378,7 @@ describe("buildSessionConfig", () => {
|
||||
const providers = [
|
||||
{ providerId: "poolside", modelId: "poolside/laguna-m.1" },
|
||||
{ providerId: "v0", modelId: "v0-1.5-md" },
|
||||
{ providerId: "xiaomi", modelId: "mimo-v2.5" },
|
||||
{ providerId: "xiaomi", modelId: "mimo-v2-omni" },
|
||||
{ providerId: "zai-coding-plan", modelId: "glm-5.2" },
|
||||
] as const
|
||||
|
||||
@@ -654,46 +651,6 @@ describe("buildSessionConfig", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the configured SDK compaction strategy when auto condense is enabled", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "agentic" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "agentic",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to basic SDK compaction for an invalid stored strategy", async () => {
|
||||
writeJson(process.env.CLINE_GLOBAL_SETTINGS_PATH!, { compactionStrategy: "invalid" })
|
||||
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => {
|
||||
if (key === "useAutoCondense") {
|
||||
return true
|
||||
}
|
||||
if (key === "subagentsEnabled") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
expect(config.compaction).toEqual({
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not enable SDK compaction when global useAutoCondense is false", async () => {
|
||||
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type CoreSessionConfig,
|
||||
getProviderAuthHandler,
|
||||
type ProviderSettings,
|
||||
readCompactionStrategyGlobally,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
type StartSessionResult,
|
||||
} from "@cline/core"
|
||||
@@ -91,8 +90,6 @@ export interface SessionConfigInput {
|
||||
export interface ActiveSession {
|
||||
/** The session ID */
|
||||
sessionId: string
|
||||
/** The config used to start the active session. */
|
||||
startConfig?: Pick<CoreSessionConfig, "providerId" | "modelId">
|
||||
/** The runtime host instance managing this session (VscodeSessionHost) */
|
||||
sdkHost: SdkSessionHost
|
||||
/** Unsubscribe function for session events */
|
||||
@@ -656,7 +653,6 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
|
||||
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
|
||||
|
||||
@@ -701,7 +697,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
? {
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: compactionStrategy,
|
||||
strategy: "basic",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("parseProviderId", () => {
|
||||
parseProviderId("poolside")
|
||||
parseProviderId("v0")
|
||||
parseProviderId("xiaomi")
|
||||
parseProviderId("tencent-tokenhub")
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -65,7 +64,6 @@ describe("isKnownProviderId", () => {
|
||||
expect(isKnownProviderId(parseProviderId("poolside"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("v0"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("xiaomi"))).toBe(true)
|
||||
expect(isKnownProviderId(parseProviderId("tencent-tokenhub"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for a custom provider id", () => {
|
||||
|
||||
@@ -57,7 +57,6 @@ const KNOWN_API_PROVIDERS = {
|
||||
nousResearch: true,
|
||||
wandb: true,
|
||||
xiaomi: true,
|
||||
"tencent-tokenhub": true,
|
||||
"cline-pass": true,
|
||||
} satisfies Record<ApiProvider, true>
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ProviderFailureTelemetryTurnGate } from "./provider-failure-telemetry"
|
||||
|
||||
describe("ProviderFailureTelemetryTurnGate", () => {
|
||||
it("captures one streaming failure per active turn", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
gate.beginTurn()
|
||||
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
|
||||
})
|
||||
|
||||
it("captures again when a new turn starts", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
gate.beginTurn()
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(false)
|
||||
|
||||
gate.beginTurn()
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
})
|
||||
|
||||
it("does not suppress streaming failures when no turn is active", () => {
|
||||
const gate = new ProviderFailureTelemetryTurnGate()
|
||||
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
expect(gate.shouldCaptureStreamingFailure()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
export const PROVIDER_FAILURE_ERROR_TYPE = {
|
||||
AUTH: "auth",
|
||||
BALANCE: "balance",
|
||||
SEND_ERROR: "send_error",
|
||||
TASK_INIT: "task_init",
|
||||
SDK_AGENT_ERROR: "sdk_agent_error",
|
||||
SDK_AGENT_DONE_ERROR: "sdk_agent_done_error",
|
||||
} as const
|
||||
|
||||
export const PROVIDER_FAILURE_PHASE = {
|
||||
PREFLIGHT: "preflight",
|
||||
STREAMING: "streaming",
|
||||
} as const
|
||||
|
||||
export type ProviderFailureErrorType = (typeof PROVIDER_FAILURE_ERROR_TYPE)[keyof typeof PROVIDER_FAILURE_ERROR_TYPE]
|
||||
|
||||
export type ProviderFailurePhase = (typeof PROVIDER_FAILURE_PHASE)[keyof typeof PROVIDER_FAILURE_PHASE]
|
||||
|
||||
export type ProviderFailureTelemetry = {
|
||||
sessionId?: string
|
||||
error: unknown
|
||||
providerId?: string
|
||||
modelId?: string
|
||||
errorType: ProviderFailureErrorType
|
||||
failurePhase: ProviderFailurePhase
|
||||
}
|
||||
|
||||
export class ProviderFailureTelemetryTurnGate {
|
||||
private turnCounter = 0
|
||||
private activeTurnId: number | undefined
|
||||
private streamingFailureCapturedTurnId: number | undefined
|
||||
|
||||
beginTurn(): void {
|
||||
this.turnCounter += 1
|
||||
this.activeTurnId = this.turnCounter
|
||||
}
|
||||
|
||||
shouldCaptureStreamingFailure(): boolean {
|
||||
if (this.activeTurnId === undefined) {
|
||||
return true
|
||||
}
|
||||
if (this.streamingFailureCapturedTurnId === this.activeTurnId) {
|
||||
return false
|
||||
}
|
||||
this.streamingFailureCapturedTurnId = this.activeTurnId
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { CoreSessionEvent } from "@cline/core"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { MessageTranslatorState } from "./message-translator"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
|
||||
import { SdkSessionEventCoordinator, type SdkSessionEventCoordinatorOptions } from "./sdk-session-event-coordinator"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
@@ -136,7 +135,6 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(clearTurnOutcome).toHaveBeenCalledOnce()
|
||||
expect(options.beginProviderFailureTelemetryTurn).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
|
||||
@@ -265,72 +263,6 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
|
||||
expect(options.sessions.setRunning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("captures provider failure telemetry for SDK agent errors", async () => {
|
||||
const error = new Error("provider failed")
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
error,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: "session-123",
|
||||
error,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not capture provider failure telemetry for SDK agent errors without an error payload", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "error",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("captures provider failure telemetry when the SDK finishes a turn with reason error", async () => {
|
||||
const { coordinator, options } = makeCoordinator()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
event: {
|
||||
type: "done",
|
||||
reason: "error",
|
||||
text: "stream failed before assistant output",
|
||||
iterations: 1,
|
||||
},
|
||||
},
|
||||
} as unknown as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: "session-123",
|
||||
error: "stream failed before assistant output",
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
@@ -367,8 +299,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
getTask: vi.fn(() => input.task),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
setTurnPhase: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
beginProviderFailureTelemetryTurn: vi.fn(),
|
||||
translateSessionEvent: vi.fn(() => input.translation ?? { messages: [], sessionEnded: false, turnComplete: false }),
|
||||
isClineFreeModel: input.isClineFreeModel,
|
||||
} as unknown as SdkSessionEventCoordinatorOptions & {
|
||||
@@ -387,8 +317,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
}
|
||||
taskHistory: SdkSessionEventCoordinatorOptions["taskHistory"] & { updateTaskUsage: ReturnType<typeof vi.fn> }
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
captureProviderApiError: ReturnType<typeof vi.fn>
|
||||
beginProviderFailureTelemetryTurn: ReturnType<typeof vi.fn>
|
||||
translateSessionEvent: ReturnType<typeof vi.fn>
|
||||
messageTranslatorState: MessageTranslatorState
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentEvent, CoreSessionEvent } from "@cline/core"
|
||||
import type { CoreSessionEvent } from "@cline/core"
|
||||
import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
|
||||
@@ -6,7 +6,6 @@ import type { ClineApiReqInfo, TurnPhase } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { MessageTranslatorState, TranslationResult } from "./message-translator"
|
||||
import { translateSessionEvent } from "./message-translator"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
|
||||
import type { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkModeCoordinator } from "./sdk-mode-coordinator"
|
||||
@@ -19,8 +18,6 @@ function normalizeModelId(modelId: string): string {
|
||||
return modelId.trim().toLowerCase()
|
||||
}
|
||||
|
||||
type AgentFailureTelemetry = Pick<ProviderFailureTelemetry, "sessionId" | "error" | "errorType"> | undefined
|
||||
|
||||
export interface SdkSessionEventCoordinatorOptions {
|
||||
messageTranslatorState: MessageTranslatorState
|
||||
sessions: SdkSessionLifecycle
|
||||
@@ -40,8 +37,6 @@ export interface SdkSessionEventCoordinatorOptions {
|
||||
* error. Optional for tests.
|
||||
*/
|
||||
setTurnPhase?: (phase: TurnPhase, anchorTs?: number) => void
|
||||
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
|
||||
beginProviderFailureTelemetryTurn?: () => void
|
||||
}
|
||||
|
||||
export class SdkSessionEventCoordinator {
|
||||
@@ -69,20 +64,10 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
|
||||
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
|
||||
const agentFailure = this.getAgentFailureTelemetry(event)
|
||||
if (agentFailure && !this.options.messageTranslatorState.isSuppressedToolApprovalDenial(agentFailure.error)) {
|
||||
this.options.captureProviderApiError?.({
|
||||
sessionId: agentFailure.sessionId,
|
||||
error: agentFailure.error,
|
||||
errorType: agentFailure.errorType,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
}
|
||||
if (event.type === "pending_prompt_submitted") {
|
||||
this.options.beginProviderFailureTelemetryTurn?.()
|
||||
this.options.messageTranslatorState.clearTurnOutcome()
|
||||
this.options.sessions.setRunning(true)
|
||||
this.options.setTurnPhase?.(PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
this.options.setTurnPhase?.("streaming")
|
||||
}
|
||||
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
|
||||
if (zeroCostPromise) {
|
||||
@@ -162,33 +147,6 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private getAgentFailureTelemetry(event: CoreSessionEvent): AgentFailureTelemetry {
|
||||
if (event.type !== "agent_event") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agentEvent: AgentEvent = event.payload.event
|
||||
if (agentEvent.type === "error") {
|
||||
if (agentEvent.error == null) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sessionId: event.payload.sessionId,
|
||||
error: agentEvent.error,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_ERROR,
|
||||
}
|
||||
}
|
||||
if (agentEvent.type === "done" && agentEvent.reason === "error") {
|
||||
const errorMessage = agentEvent.text.trim() || "SDK agent finished with error"
|
||||
return {
|
||||
sessionId: event.payload.sessionId,
|
||||
error: errorMessage,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private zeroCostForFreeClineModel(result: TranslationResult): Promise<void> | undefined {
|
||||
const hasUsageCost = typeof result.usage?.totalCost === "number" && result.usage.totalCost !== 0
|
||||
const hasMessageCost = result.messages.some((message) => {
|
||||
|
||||
@@ -41,24 +41,6 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("stores the provider and model config used to start the active session", async () => {
|
||||
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
|
||||
await lifecycle.startNewSession({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
},
|
||||
} as StartInput)
|
||||
|
||||
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4",
|
||||
})
|
||||
})
|
||||
|
||||
it("reuses the shared session host across sessions", async () => {
|
||||
const sdkHost = makeSdkHost({
|
||||
start: vi.fn().mockResolvedValueOnce({ sessionId: "session-1" }).mockResolvedValueOnce({ sessionId: "session-2" }),
|
||||
@@ -165,23 +147,6 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(false)
|
||||
})
|
||||
|
||||
it("calls the send-start hook before sending to the SDK host", async () => {
|
||||
const onSendStart = vi.fn()
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const sdkHost = makeSdkHost({ send })
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle({ onSendStart })
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({} as any)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
lifecycle.fireAndForgetSend(sdkHost as any, "session-123", "hello")
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalled())
|
||||
|
||||
expect(onSendStart).toHaveBeenCalledWith("session-123")
|
||||
expect(onSendStart.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
it("leaves the active session running when a message is queued", async () => {
|
||||
const onSendComplete = vi.fn()
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -463,13 +428,8 @@ describe("SdkSessionLifecycle", () => {
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
|
||||
await lifecycle.startNewSession({
|
||||
config: {
|
||||
sessionId: "source-session",
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5",
|
||||
},
|
||||
} as StartInput)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
await lifecycle.startNewSession({ config: { sessionId: "source-session" } } as any)
|
||||
const result = await lifecycle.restoreActiveSession({
|
||||
sessionId: "source-session",
|
||||
checkpointRunCount: 1,
|
||||
@@ -477,10 +437,6 @@ describe("SdkSessionLifecycle", () => {
|
||||
|
||||
expect(result).toBe(restored)
|
||||
expect(lifecycle.getActiveSession()?.sessionId).toBe("restored-session")
|
||||
expect(lifecycle.getActiveSession()?.startConfig).toEqual({
|
||||
providerId: "openai",
|
||||
modelId: "gpt-5",
|
||||
})
|
||||
expect(sdkHost.stop).toHaveBeenCalledWith("source-session")
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface SdkSessionLifecycleOptions {
|
||||
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
|
||||
/** Shared SDK telemetry service owned by SdkController. */
|
||||
telemetry?: ITelemetryService
|
||||
onSendStart?: (sessionId: string) => void
|
||||
onSendComplete: (sessionId: string) => Promise<void> | void
|
||||
onSendError: (error: unknown, sessionId: string) => Promise<void> | void
|
||||
}
|
||||
@@ -127,12 +126,6 @@ export class SdkSessionLifecycle {
|
||||
})
|
||||
this.activeSession = {
|
||||
sessionId: startResult.sessionId,
|
||||
startConfig: startInput.config
|
||||
? {
|
||||
providerId: startInput.config.providerId,
|
||||
modelId: startInput.config.modelId,
|
||||
}
|
||||
: undefined,
|
||||
sdkHost,
|
||||
unsubscribe: () => {},
|
||||
startResult,
|
||||
@@ -189,12 +182,6 @@ export class SdkSessionLifecycle {
|
||||
this.activeSession = {
|
||||
...activeSession,
|
||||
sessionId: restored.sessionId,
|
||||
startConfig: input.start?.config
|
||||
? {
|
||||
providerId: input.start.config.providerId,
|
||||
modelId: input.start.config.modelId,
|
||||
}
|
||||
: activeSession.startConfig,
|
||||
startResult: restored.startResult,
|
||||
isRunning: false,
|
||||
}
|
||||
@@ -337,7 +324,6 @@ export class SdkSessionLifecycle {
|
||||
Logger.debug(`[SdkController] Ignoring ${label} of superseded send for session: ${sessionId}`)
|
||||
return true
|
||||
}
|
||||
this.options.onSendStart?.(sessionId)
|
||||
sdkHost
|
||||
.send({
|
||||
sessionId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "./provider-failure-telemetry"
|
||||
import { SdkTaskStartCoordinator, type SdkTaskStartCoordinatorOptions } from "./sdk-task-start-coordinator"
|
||||
|
||||
vi.mock("@/shared/services/Logger", () => ({
|
||||
@@ -72,7 +71,6 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs auth")
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -83,27 +81,17 @@ describe("SdkTaskStartCoordinator", () => {
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).toHaveBeenCalledWith("needs clinepass auth")
|
||||
expect(options.captureProviderApiError).not.toHaveBeenCalled()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("emits a plain chat error when session start fails (e.g. provider misconfigured)", async () => {
|
||||
const { coordinator, options, state } = makeCoordinator()
|
||||
const error = new Error("No model configured for provider openai")
|
||||
options.sessions.startNewSession.mockRejectedValue(error)
|
||||
options.sessions.startNewSession.mockRejectedValue(new Error("No model configured for provider openai"))
|
||||
|
||||
const sessionId = await coordinator.initTask("do something")
|
||||
|
||||
expect(sessionId).toBeUndefined()
|
||||
expect(options.emitClineAuthError).not.toHaveBeenCalled()
|
||||
expect(options.captureProviderApiError).toHaveBeenCalledWith({
|
||||
sessionId: state.task?.taskId,
|
||||
error,
|
||||
providerId: "anthropic",
|
||||
modelId: "model",
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
expect(state.task?.taskId).toEqual(expect.any(String))
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
@@ -254,7 +242,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
|
||||
isClineProviderActive: vi.fn(() => false),
|
||||
emitClineAuthError: vi.fn(),
|
||||
captureProviderApiError: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SdkTaskStartCoordinatorOptions & {
|
||||
sessions: SdkTaskStartCoordinatorOptions["sessions"] & {
|
||||
@@ -279,7 +266,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
resolveContextMentions: ReturnType<typeof vi.fn>
|
||||
isClineProviderActive: ReturnType<typeof vi.fn>
|
||||
emitClineAuthError: ReturnType<typeof vi.fn>
|
||||
captureProviderApiError: ReturnType<typeof vi.fn>
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Settings } from "@shared/storage/state-keys"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
|
||||
import type { SdkSessionLifecycle } from "./sdk-session-lifecycle"
|
||||
@@ -53,7 +52,6 @@ export interface SdkTaskStartCoordinatorOptions {
|
||||
resolveContextMentions: (text: string) => Promise<string>
|
||||
isClineProviderActive: () => boolean
|
||||
emitClineAuthError: (task?: string) => void
|
||||
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
|
||||
postStateToWebview: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -69,8 +67,6 @@ export class SdkTaskStartCoordinator {
|
||||
): Promise<string | undefined> {
|
||||
Logger.log(`[SdkController] initTask called: "${prompt?.substring(0, 50)}"`)
|
||||
let taskSessionId: string | undefined
|
||||
let providerId: string | undefined
|
||||
let modelId: string | undefined
|
||||
try {
|
||||
await this.options.clearTask()
|
||||
|
||||
@@ -86,8 +82,6 @@ export class SdkTaskStartCoordinator {
|
||||
cwd,
|
||||
mode,
|
||||
})
|
||||
providerId = config.providerId
|
||||
modelId = config.modelId
|
||||
|
||||
Logger.log(
|
||||
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
|
||||
@@ -97,8 +91,6 @@ export class SdkTaskStartCoordinator {
|
||||
Logger.warn(
|
||||
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
|
||||
)
|
||||
// No task/session id exists yet, so this preflight auth UI path is
|
||||
// intentionally not recorded as task-joinable provider error telemetry.
|
||||
this.options.emitClineAuthError(prompt)
|
||||
return undefined
|
||||
}
|
||||
@@ -149,14 +141,6 @@ export class SdkTaskStartCoordinator {
|
||||
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
|
||||
return taskSessionId
|
||||
} catch (error) {
|
||||
this.options.captureProviderApiError?.({
|
||||
sessionId: taskSessionId,
|
||||
error,
|
||||
providerId,
|
||||
modelId,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.TASK_INIT,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
|
||||
})
|
||||
this.handleInitError(error, taskSessionId)
|
||||
await this.options.postStateToWebview().catch((postError) => {
|
||||
Logger.error("[SdkController] Failed to post state after init error:", postError)
|
||||
|
||||
@@ -109,14 +109,6 @@ export class ClineError extends Error {
|
||||
})
|
||||
}
|
||||
|
||||
public get status(): number | undefined {
|
||||
return this._error.status
|
||||
}
|
||||
|
||||
public get requestId(): string | undefined {
|
||||
return this._error.request_id
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a stringified error into a ClineError instance.
|
||||
*/
|
||||
|
||||
@@ -338,12 +338,6 @@ export class TelemetryService {
|
||||
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
|
||||
// Tracks when a button is clicked
|
||||
BUTTON_CLICKED: "ui.button_clicked",
|
||||
// Tracks when the Cline panel becomes visible
|
||||
PANEL_OPENED: "ui.panel_opened",
|
||||
// Tracks when the user explicitly starts a new task flow
|
||||
NEW_TASK_CLICKED: "ui.new_task_clicked",
|
||||
// Tracks when the user submits chat composer content
|
||||
PROMPT_SUBMITTED: "ui.prompt_submitted",
|
||||
// Tracks when the rules menu button is clicked
|
||||
RULES_MENU_OPENED: "ui.rules_menu_opened",
|
||||
},
|
||||
@@ -1376,34 +1370,6 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
public capturePanelOpened(source?: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.PANEL_OPENED,
|
||||
properties: { source },
|
||||
})
|
||||
}
|
||||
|
||||
public captureNewTaskClicked(source?: string, hasActiveTask?: boolean) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.NEW_TASK_CLICKED,
|
||||
properties: { source, hasActiveTask },
|
||||
})
|
||||
}
|
||||
|
||||
public capturePromptSubmitted(args: {
|
||||
source?: string
|
||||
hasText?: boolean
|
||||
hasImages?: boolean
|
||||
hasFiles?: boolean
|
||||
hasActiveTask?: boolean
|
||||
textLength?: number
|
||||
}) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.UI.PROMPT_SUBMITTED,
|
||||
properties: args,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records telemetry when an API provider returns an error
|
||||
* @param ulid Unique identifier for the task
|
||||
@@ -1420,8 +1386,6 @@ export class TelemetryService {
|
||||
provider?: string
|
||||
errorStatus?: number | undefined
|
||||
requestId?: string | undefined
|
||||
errorType?: string | undefined
|
||||
failurePhase?: string | undefined
|
||||
isNativeToolCall?: boolean
|
||||
}) {
|
||||
this.capture({
|
||||
@@ -1438,16 +1402,12 @@ export class TelemetryService {
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
})
|
||||
const errorAttributes = {
|
||||
ulid: args.ulid,
|
||||
model: args.model,
|
||||
provider: args.provider,
|
||||
error_status: args.errorStatus,
|
||||
error_type: args.errorType,
|
||||
failure_phase: args.failurePhase,
|
||||
}
|
||||
const errorCount = this.incrementTaskCounter(this.taskErrorCounts, args.ulid)
|
||||
this.recordHistogram(TelemetryService.METRICS.ERRORS.PER_TASK, errorCount, errorAttributes)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it } from "bun:test"
|
||||
import { ApiFormat } from "@shared/proto/cline/models"
|
||||
import * as assert from "assert"
|
||||
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE } from "../../../sdk/provider-failure-telemetry"
|
||||
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../providers/ITelemetryProvider"
|
||||
import { TelemetryMetadata, TelemetryService } from "../TelemetryService"
|
||||
|
||||
@@ -289,8 +288,6 @@ describe("TelemetryService metrics", () => {
|
||||
errorMessage: "boom",
|
||||
provider: "anthropic",
|
||||
errorStatus: 500,
|
||||
errorType: PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR,
|
||||
failurePhase: PROVIDER_FAILURE_PHASE.STREAMING,
|
||||
})
|
||||
|
||||
assert.strictEqual(provider.counters.length, 1)
|
||||
@@ -301,8 +298,6 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(entry.attributes.provider, "anthropic")
|
||||
assert.strictEqual(entry.attributes.model, "claude")
|
||||
assert.strictEqual(entry.attributes.error_status, 500)
|
||||
assert.strictEqual(entry.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
|
||||
assert.strictEqual(entry.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
assert.strictEqual(provider.histograms.length, 1)
|
||||
const errorHistogram = provider.histograms[0]
|
||||
assert.strictEqual(errorHistogram.name, TelemetryService.METRICS.ERRORS.PER_TASK)
|
||||
@@ -311,8 +306,6 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(errorHistogram.attributes.provider, "anthropic")
|
||||
assert.strictEqual(errorHistogram.attributes.model, "claude")
|
||||
assert.strictEqual(errorHistogram.attributes.error_status, 500)
|
||||
assert.strictEqual(errorHistogram.attributes.error_type, PROVIDER_FAILURE_ERROR_TYPE.SDK_AGENT_DONE_ERROR)
|
||||
assert.strictEqual(errorHistogram.attributes.failure_phase, PROVIDER_FAILURE_PHASE.STREAMING)
|
||||
})
|
||||
|
||||
it("captureTaskCompleted records completion payload with TTFT and duration histograms", () => {
|
||||
|
||||
@@ -109,7 +109,6 @@ export interface ExtensionState {
|
||||
mcpResponsesCollapsed?: boolean
|
||||
yoloModeToggled?: boolean
|
||||
useAutoCondense?: boolean
|
||||
compactionStrategy?: string
|
||||
subagentsEnabled?: boolean
|
||||
worktreesEnabled?: ClineFeatureSetting
|
||||
customPrompt?: string
|
||||
|
||||
@@ -49,7 +49,6 @@ export type ApiProvider =
|
||||
| "nousResearch"
|
||||
| "wandb"
|
||||
| "xiaomi"
|
||||
| "tencent-tokenhub"
|
||||
|
||||
export const DEFAULT_API_PROVIDER = "openrouter" as ApiProvider
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { convertApiConfigurationToProto, convertProtoToApiConfiguration } from "
|
||||
|
||||
describe("api configuration provider conversion", () => {
|
||||
it("round-trips SDK provider ids added after the legacy enum list", () => {
|
||||
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "tencent-tokenhub", "zai-coding-plan"]
|
||||
const providers: ApiProvider[] = ["poolside", "v0", "xiaomi", "zai-coding-plan"]
|
||||
|
||||
for (const provider of providers) {
|
||||
const proto = convertApiConfigurationToProto({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
|
||||
|
||||
export interface OAuthCredentials {
|
||||
@@ -13,28 +12,6 @@ export interface StartSessionResult {
|
||||
|
||||
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
|
||||
|
||||
export type GlobalCompactionStrategy = "basic" | "agentic"
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
|
||||
return settings.compactionStrategy === "agentic" ? "agentic" : "basic"
|
||||
} catch {
|
||||
return "basic"
|
||||
}
|
||||
}
|
||||
|
||||
export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompactionStrategy): void {
|
||||
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
if (filePath) {
|
||||
let settings = {}
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(filePath, "utf8"))
|
||||
} catch {}
|
||||
writeFileSync(filePath, JSON.stringify({ ...settings, compactionStrategy }))
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateCommandOutput(output: string): string {
|
||||
return output
|
||||
}
|
||||
|
||||
-44
@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
const newTask = vi.fn().mockResolvedValue(undefined)
|
||||
const askResponse = vi.fn().mockResolvedValue(undefined)
|
||||
const condense = vi.fn().mockResolvedValue(undefined)
|
||||
const trackIntent = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock("@/services/grpc-client", () => ({
|
||||
TaskServiceClient: {
|
||||
@@ -19,9 +18,6 @@ vi.mock("@/services/grpc-client", () => ({
|
||||
condense: (req: unknown) => condense(req),
|
||||
reportBug: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
UiServiceClient: {
|
||||
trackIntent: (req: unknown) => trackIntent(req),
|
||||
},
|
||||
}))
|
||||
|
||||
// Proto request factories just echo their input so we can assert on it.
|
||||
@@ -29,9 +25,6 @@ vi.mock("@shared/proto/cline/task", () => ({
|
||||
AskResponseRequest: { create: (x: unknown) => x },
|
||||
NewTaskRequest: { create: (x: unknown) => x },
|
||||
}))
|
||||
vi.mock("@shared/proto/cline/ui", () => ({
|
||||
IntentEvent: { create: (x: unknown) => x },
|
||||
}))
|
||||
vi.mock("@shared/proto/cline/common", () => ({
|
||||
EmptyRequest: { create: (x: unknown) => x },
|
||||
StringRequest: { create: (x: unknown) => x },
|
||||
@@ -100,8 +93,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
askResponse.mockResolvedValue(undefined)
|
||||
condense.mockReset()
|
||||
condense.mockResolvedValue(undefined)
|
||||
trackIntent.mockReset()
|
||||
trackIntent.mockResolvedValue(undefined)
|
||||
mockTurnState = undefined
|
||||
})
|
||||
|
||||
@@ -117,7 +108,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(condense).toHaveBeenCalledWith(expect.objectContaining({ value: "compact" }))
|
||||
expect(newTask).not.toHaveBeenCalled()
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("routes the /smol alias to the condense RPC as well", async () => {
|
||||
@@ -131,7 +121,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(condense).toHaveBeenCalledTimes(1)
|
||||
expect(newTask).not.toHaveBeenCalled()
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not intercept /compact when there is no active task (starts a new task instead)", async () => {
|
||||
@@ -144,17 +133,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
|
||||
expect(condense).not.toHaveBeenCalled()
|
||||
expect(newTask).toHaveBeenCalledTimes(1)
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: false,
|
||||
textLength: "/compact".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("after a completed turn (no clineAsk), Enter continues the conversation via askResponse — NOT newTask", async () => {
|
||||
@@ -170,17 +148,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
expect(askResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ responseType: "messageResponse", text: "another question" }),
|
||||
)
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: true,
|
||||
textLength: "another question".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("shows pending composer state before a follow-up askResponse resolves", async () => {
|
||||
@@ -412,17 +379,6 @@ describe("useMessageHandlers — send routing", () => {
|
||||
|
||||
expect(newTask).toHaveBeenCalledTimes(1)
|
||||
expect(askResponse).not.toHaveBeenCalled()
|
||||
expect(trackIntent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: true,
|
||||
hasImages: false,
|
||||
hasFiles: false,
|
||||
hasActiveTask: false,
|
||||
textLength: "brand new task".length,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("restores pending new-task UI state when the RPC fails", async () => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { SlashServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { SlashServiceClient, TaskServiceClient } from "@/services/grpc-client"
|
||||
import type { ButtonActionType } from "../shared/buttonConfig"
|
||||
import type { ChatState, MessageHandlers } from "../types/chatTypes"
|
||||
|
||||
@@ -65,19 +64,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
let messageSent = false
|
||||
const trackPromptSubmitted = (hasActiveTask: boolean) => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "prompt_submitted",
|
||||
source: "chat_submit",
|
||||
hasText: messageToSend.length > 0,
|
||||
hasImages: images.length > 0,
|
||||
hasFiles: files.length > 0,
|
||||
hasActiveTask,
|
||||
textLength: messageToSend.length,
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track prompt submit:", error))
|
||||
}
|
||||
const clearSentMessageState = () => {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
@@ -98,7 +84,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
request: ReturnType<typeof AskResponseRequest.create>,
|
||||
options: { showPendingMessage?: boolean } = {},
|
||||
) => {
|
||||
trackPromptSubmitted(true)
|
||||
clearSentMessageState()
|
||||
if (options.showPendingMessage) {
|
||||
const afterTs = Math.max(0, ...messages.map((message) => message.ts))
|
||||
@@ -133,7 +118,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
files,
|
||||
})
|
||||
clearSentMessageState()
|
||||
trackPromptSubmitted(false)
|
||||
try {
|
||||
await TaskServiceClient.newTask(request)
|
||||
} catch (error) {
|
||||
@@ -272,16 +256,9 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
|
||||
|
||||
// Start a new task
|
||||
const startNewTask = useCallback(async () => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "new_task_clicked",
|
||||
source: "chat_new_task",
|
||||
hasActiveTask: messages.length > 0,
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track new task click:", error))
|
||||
setActiveQuote(null)
|
||||
await TaskServiceClient.clearTask(EmptyRequest.create({}))
|
||||
}, [messages.length, setActiveQuote])
|
||||
}, [setActiveQuote])
|
||||
|
||||
// Clear input state helper
|
||||
const clearInputState = useCallback(() => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { HistoryIcon, PlusIcon, PuzzleIcon, SettingsIcon, UserCircleIcon } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
|
||||
export const Navbar = () => {
|
||||
@@ -18,12 +17,6 @@ export const Navbar = () => {
|
||||
tooltip: "New Task",
|
||||
icon: PlusIcon,
|
||||
navigate: () => {
|
||||
UiServiceClient.trackIntent(
|
||||
IntentEvent.create({
|
||||
action: "new_task_clicked",
|
||||
source: "navbar",
|
||||
}),
|
||||
).catch((error) => console.error("Failed to track new task click:", error))
|
||||
// Close the current task, then navigate to the chat view
|
||||
TaskServiceClient.clearTask({})
|
||||
.catch((error) => {
|
||||
|
||||
-1
@@ -69,7 +69,6 @@ describe("providerSettingsRegistry", () => {
|
||||
["nousResearch", "NousResearch", undefined],
|
||||
["poolside", "Poolside", undefined],
|
||||
["sambanova", "SambaNova", "https://docs.sambanova.ai/cloud/docs/get-started/overview"],
|
||||
["tencent-tokenhub", "Tencent TokenHub", "https://cloud.tencent.com/document/product/1823/130050"],
|
||||
["vercel-ai-gateway", "Vercel AI Gateway", "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai"],
|
||||
["v0", "Vercel v0", undefined],
|
||||
["wandb", "W&B", "https://wandb.ai"],
|
||||
|
||||
@@ -97,9 +97,6 @@ const GENERIC_PROVIDER_PRESENTATION_OVERRIDES: Record<string, GenericProviderPre
|
||||
signupUrl: "https://wandb.ai",
|
||||
},
|
||||
xiaomi: {},
|
||||
"tencent-tokenhub": {
|
||||
signupUrl: "https://cloud.tencent.com/document/product/1823/130050",
|
||||
},
|
||||
"zai-coding-plan": {},
|
||||
}
|
||||
|
||||
@@ -160,7 +157,6 @@ const FALLBACK_GENERIC_PROVIDER_NAMES = {
|
||||
v0: "Vercel v0",
|
||||
wandb: "W&B",
|
||||
xiaomi: "Xiaomi",
|
||||
"tencent-tokenhub": "Tencent TokenHub",
|
||||
"zai-coding-plan": "Z.AI Coding Plan",
|
||||
} as const
|
||||
|
||||
|
||||
+5
-34
@@ -1,27 +1,23 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import FeatureSettingsSection from "./FeatureSettingsSection"
|
||||
|
||||
const mockUpdateSetting = vi.fn()
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
value: {
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => ({
|
||||
enableCheckpointsSetting: true,
|
||||
hooksEnabled: false,
|
||||
showFeatureTips: false,
|
||||
mcpDisplayMode: "rich",
|
||||
yoloModeToggled: false,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: true },
|
||||
focusChainSettings: { enabled: false, remindClineInterval: 6 },
|
||||
remoteConfigSettings: {},
|
||||
backgroundEditEnabled: false,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(() => mockExtensionState.value),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/settingsHandlers", () => ({
|
||||
@@ -29,15 +25,6 @@ vi.mock("../utils/settingsHandlers", () => ({
|
||||
}))
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
beforeEach(() => {
|
||||
mockUpdateSetting.mockClear()
|
||||
mockExtensionState.value = {
|
||||
...mockExtensionState.value,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
}
|
||||
})
|
||||
|
||||
it("renders Hooks feature toggle", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
@@ -62,22 +49,6 @@ describe("FeatureSettingsSection", () => {
|
||||
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("renders the Auto Compact Strategy setting in the Agent section", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
expect(screen.getByText("Auto Compact Strategy")).toBeTruthy()
|
||||
|
||||
const agentSection = container.querySelector("#agent-features")
|
||||
expect(agentSection?.textContent).toContain("Basic")
|
||||
})
|
||||
|
||||
it("disables Auto Compact Strategy when Auto Compact is off", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
const strategySelect = container.querySelector("#agent-features button[role='combobox']")
|
||||
expect(strategySelect).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("calls updateSetting with hooksEnabled when toggled", () => {
|
||||
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
mcpDisplayMode,
|
||||
yoloModeToggled,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
subagentsEnabled,
|
||||
worktreesEnabled,
|
||||
remoteConfigSettings,
|
||||
@@ -202,22 +201,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
onChange={(checked) => updateSetting(feature.settingKey, checked)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-2 py-3">
|
||||
<Label className="text-sm font-medium text-foreground">Auto Compact Strategy</Label>
|
||||
<p className="text-xs text-muted-foreground">Controls how auto compaction rewrites context.</p>
|
||||
<Select
|
||||
disabled={!useAutoCondense}
|
||||
onValueChange={(value) => updateSetting("compactionStrategy", value)}
|
||||
value={compactionStrategy ?? "basic"}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">Basic</SelectItem>
|
||||
<SelectItem value="agentic">Agentic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
yoloModeToggled: false,
|
||||
customPrompt: undefined,
|
||||
useAutoCondense: false,
|
||||
compactionStrategy: "basic",
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.55
|
||||
|
||||
- Add Tencent TokenHub as a provider
|
||||
- Add a compaction strategy setting so you can choose how context compaction works
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3), where a shallow session could auto-compact immediately and reduce the initial task to just the input wrapper
|
||||
- Use a curated default when migrating legacy provider settings
|
||||
- Advertise run commands as shell strings
|
||||
- Refresh the bundled model catalog with the latest provider models
|
||||
|
||||
## 0.0.54
|
||||
|
||||
- Improve basic compaction token budgeting so context compaction is more accurate
|
||||
|
||||
@@ -18,6 +18,7 @@ What a plugin can do:
|
||||
| [custom-compaction.ts](./custom-compaction.ts) | Provider-message compaction via `registerMessageBuilder` | Rewrites oversized provider-bound message history by preserving the first user message and recent context, then replacing older middle history with a structured summary of roles, tools, files, and highlights. |
|
||||
| [background-terminal.ts](./background-terminal.ts) | Detached shell jobs with persisted logs and session steering | Registers `start_background_command`, `get_background_command`, and `delete_background_command` so agents can launch long-running shell commands, poll stdout/stderr tails, clean up job metadata, and receive completion summaries as steer messages. |
|
||||
| [automation-events.ts](./automation-events.ts) | Plugin-emitted automation events | Registers a normalized `local.plugin_event` automation event type and, when `CLINE_LOCAL_EVENT_INTERVAL_MS` is set, periodically emits demo events into Cline automation. |
|
||||
| [github-pr-dashboard/](./github-pr-dashboard/) | Scheduled GitHub PR dashboard via pre-run hook gate | Fetches PR metrics before inference, paginates open PRs for accurate counts, stops when the dashboard snapshot is unchanged, and asks the agent to update a Markdown dashboard only when counts, review wait times, trends, authors, or reviewers changed. Includes a `run-once` preview that writes Markdown and HTML without a model call. |
|
||||
| [gitignore-read-files-guard.ts](./gitignore-read-files-guard.ts) | Runtime hook policy for workspace `.gitignore` boundaries | Uses `beforeTool` to inspect `read_files`, `editor`, and `apply_patch` requests and skips them when target paths match workspace `.gitignore` rules, preventing ignored files from being read or modified. |
|
||||
| [env-blocker.ts](./env-blocker.ts) | Deterministic secret protection via `beforeTool` | Uses `beforeTool` to block `read_files` and `run_commands` (e.g. `cat .env`) calls that read `.env` secret files, while leaving `.env.example`/`.env.sample`/`.env.template` readable. A hard guarantee where an AGENTS.md rule is only a suggestion. |
|
||||
| [web-search.ts](./web-search.ts) | `web_search` tool backed by an Exa API key | Adds a `web_search` tool that queries Exa for current public web results, with optional result limits, domain filters, recency windows, and country localization. Requires `EXA_API_KEY`. |
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# GitHub PR Dashboard Gate Plugin
|
||||
|
||||
A scheduled GitHub PR dashboard example that uses a deterministic `beforeRun`
|
||||
hook to decide whether an agent should run.
|
||||
|
||||
The hook fetches PR data from GitHub, computes dashboard metrics, hashes the
|
||||
snapshot, and exits before inference if nothing changed:
|
||||
|
||||
```ts
|
||||
{ stop: true, reason: "no GitHub PR dashboard changes, exiting" }
|
||||
```
|
||||
|
||||
When metrics changed, the plugin injects a dashboard-update handoff before the
|
||||
first model request. The agent can then update the requested dashboard file and
|
||||
summarize what changed.
|
||||
|
||||
## Fastest working demo, no agent required
|
||||
|
||||
This writes a Markdown dashboard and a browser-friendly HTML dashboard directly.
|
||||
It does not call a model.
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
```
|
||||
|
||||
`--repo` is the only required input for the preview command. `GITHUB_TOKEN` is
|
||||
not required for public repositories, but using `gh auth token` avoids GitHub's
|
||||
low unauthenticated API rate limit.
|
||||
|
||||
This preview does not install the plugin or create a schedule. It only proves the
|
||||
deterministic gate and dashboard rendering work locally.
|
||||
|
||||
For a disposable smoke test that writes under `/tmp`:
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
export GITHUB_PR_DASHBOARD_PATH=/tmp/github-pr-dashboard.md
|
||||
export GITHUB_PR_DASHBOARD_HTML_PATH=/tmp/github-pr-dashboard.html
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
|
||||
rm -f "$GITHUB_PR_DASHBOARD_STATE_PATH"
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
```
|
||||
|
||||
If `--open` is omitted, open the generated file manually:
|
||||
|
||||
```bash
|
||||
open /tmp/github-pr-dashboard.html
|
||||
```
|
||||
|
||||
Run the same command again without deleting the state file. If the PR metrics did
|
||||
not change, the JSON output will include:
|
||||
|
||||
```json
|
||||
{ "changed": false, "stop": true }
|
||||
```
|
||||
|
||||
The preview still rewrites the Markdown/HTML files so you can inspect the latest
|
||||
snapshot even when the scheduled hook would skip the model call.
|
||||
|
||||
When dashboard data changes after a previous run, the JSON output and scheduled
|
||||
agent handoff include a deterministic change summary, for example:
|
||||
|
||||
```text
|
||||
- Open PRs: 587 → 591 (+4)
|
||||
- Recently closed PRs: 188 → 193 (+5)
|
||||
- Newly waiting for review: cline/cline#123 Example PR title
|
||||
```
|
||||
|
||||
## What the dashboard covers
|
||||
|
||||
- Open PR count, fetched with pagination so large repositories are counted
|
||||
accurately
|
||||
- New open PR count in the recent window
|
||||
- Recently closed PR count, fetched separately from the bounded recent activity
|
||||
sample
|
||||
- How long open PRs have been waiting for review
|
||||
- PR volume trend over time
|
||||
- Leading PR authors this week and this month
|
||||
- Leading PR reviewers this week and this month
|
||||
- Per-repository breakdown
|
||||
|
||||
## Configuration
|
||||
|
||||
Required:
|
||||
|
||||
```bash
|
||||
# Preview CLI:
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline
|
||||
|
||||
# Installed plugin / scheduled runs:
|
||||
export GITHUB_REPOSITORIES=cline/cline,owner/other-repo
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN=github_pat_...
|
||||
export GH_TOKEN=github_pat_...
|
||||
|
||||
export GITHUB_PR_DASHBOARD_PATH=github-pr-dashboard.md
|
||||
export GITHUB_PR_DASHBOARD_HTML_PATH=github-pr-dashboard.html
|
||||
|
||||
# Recent activity sample size for closed/review/trend detail. Defaults to 25.
|
||||
# This is not an open PR count cap; open PRs are paginated separately.
|
||||
export GITHUB_PR_DASHBOARD_MAX_PRS=25
|
||||
|
||||
# Pagination caps for exact open counts and recently closed scans. Default 10.
|
||||
export GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES=10
|
||||
export GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES=10
|
||||
|
||||
export GITHUB_PR_DASHBOARD_NEW_HOURS=24
|
||||
export GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS=7
|
||||
export GITHUB_PR_DASHBOARD_TREND_DAYS=14
|
||||
|
||||
# Default:
|
||||
# ${CLINE_DATA_DIR:-~/.cline/data}/plugins/github-pr-dashboard/state.json
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
```
|
||||
|
||||
The state file stores the last dashboard snapshot hash, timestamp, and bounded
|
||||
rendered snapshot. It does not store GitHub tokens or raw GitHub API responses.
|
||||
The previous snapshot is used only to decide whether to wake the agent and to
|
||||
produce the change summary for day-to-day dashboard updates.
|
||||
|
||||
## Scheduled Cline usage
|
||||
|
||||
Nothing is scheduled by default. To make this run automatically, install the
|
||||
plugin into a workspace and create a Cline schedule.
|
||||
|
||||
Install the plugin into the workspace first:
|
||||
|
||||
```bash
|
||||
cline plugin install ./sdk/examples/plugins/github-pr-dashboard --cwd /path/to/workspace
|
||||
```
|
||||
|
||||
Then create a schedule with any cron pattern you want:
|
||||
|
||||
```bash
|
||||
cline schedule create "GitHub PR Dashboard" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--workspace /path/to/workspace \
|
||||
--mode act \
|
||||
--prompt "Update the GitHub PR dashboard if the pre-run hook provides changed dashboard data. Only edit the dashboard file requested by the hook."
|
||||
```
|
||||
|
||||
The schedule can wake as often as you want. The `beforeRun` hook determines
|
||||
whether the agent should actually run.
|
||||
|
||||
## Manual gate smoke test
|
||||
|
||||
This exercises the deterministic gate without writing dashboard files and without
|
||||
starting an agent/model:
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
export GITHUB_REPOSITORIES=cline/cline
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
rm -f "$GITHUB_PR_DASHBOARD_STATE_PATH"
|
||||
|
||||
bun -e '
|
||||
import { runGitHubPrDashboardGate } from "./sdk/examples/plugins/github-pr-dashboard/src/gate.ts";
|
||||
const result = await runGitHubPrDashboardGate();
|
||||
console.log(JSON.stringify({
|
||||
stop: result.stop ?? false,
|
||||
reason: result.reason,
|
||||
dashboardPath: result.dashboardPath,
|
||||
snapshotHash: result.snapshotHash,
|
||||
summary: result.snapshot?.summary,
|
||||
hasHandoff: Boolean(result.handoffText)
|
||||
}, null, 2));
|
||||
'
|
||||
```
|
||||
|
||||
Run it once to get a handoff, then run it again without deleting state. The
|
||||
second run should stop if the PR metrics did not change.
|
||||
|
||||
## Verify locally
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
bun -F cline-github-pr-dashboard-plugin test
|
||||
bun -F cline-github-pr-dashboard-plugin typecheck
|
||||
bun biome check sdk/examples/plugins/github-pr-dashboard sdk/examples/plugins/README.md --diagnostic-level=error
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "cline-github-pr-dashboard-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pre-run hook gate that checks GitHub PR dashboard metrics and only wakes an agent when dashboard data changes.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preview": "bun run src/preview.ts",
|
||||
"run-once": "bun run src/preview.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": [
|
||||
"./src/index.ts"
|
||||
],
|
||||
"capabilities": [
|
||||
"hooks"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*",
|
||||
"@cline/shared": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@cline/shared": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.13",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "../gate";
|
||||
|
||||
const env = {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS: "5",
|
||||
GITHUB_PR_DASHBOARD_PATH: "docs/pr-dashboard.md",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const pull = {
|
||||
number: 1,
|
||||
title: "Dashboard PR",
|
||||
state: "open",
|
||||
draft: false,
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-09T00:00:00Z",
|
||||
updated_at: "2026-06-09T12:00:00Z",
|
||||
requested_reviewers: [{ login: "amy" }],
|
||||
};
|
||||
|
||||
describe("github PR dashboard gate", () => {
|
||||
it("returns handoff when dashboard snapshot changes", async () => {
|
||||
const writeState = vi.fn();
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => ({ version: 1 }),
|
||||
writeState,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(result.dashboardPath).toBe("docs/pr-dashboard.md");
|
||||
expect(result.handoffText).toContain(
|
||||
"Dashboard path to update: docs/pr-dashboard.md",
|
||||
);
|
||||
expect(result.handoffText).toContain("# GitHub PR Dashboard");
|
||||
expect(result.changeSummary).toEqual([
|
||||
"Initial dashboard snapshot captured; future runs will summarize changes from this baseline.",
|
||||
]);
|
||||
expect(writeState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pendingSnapshotHash: result.snapshotHash,
|
||||
pendingSnapshot: result.snapshot,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops before model when snapshot hash is unchanged", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: first.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: vi.fn(),
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(first.stop).toBeUndefined();
|
||||
expect(second.stop).toBe(true);
|
||||
expect(second.reason).toBe("no GitHub PR dashboard changes, exiting");
|
||||
expect(second.changeSummary).toEqual([]);
|
||||
expect(second.handoffText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops before model when an identical snapshot is already pending", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
const writeState = vi.fn((nextState) => {
|
||||
state = nextState;
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(first.stop).toBeUndefined();
|
||||
expect(state.pendingSnapshotHash).toBe(first.snapshotHash);
|
||||
expect(second.stop).toBe(true);
|
||||
expect(second.handoffText).toBeUndefined();
|
||||
expect(second.changeSummary).toEqual([]);
|
||||
expect(writeState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pendingSnapshotHash: first.snapshotHash,
|
||||
}),
|
||||
);
|
||||
expect(state.lastSnapshotHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes deterministic change summary from previous snapshot", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: first.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: () => undefined,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) =>
|
||||
url.includes("/reviews")
|
||||
? []
|
||||
: [
|
||||
pull,
|
||||
{
|
||||
...pull,
|
||||
number: 2,
|
||||
title: "Second PR",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(second.stop).toBeUndefined();
|
||||
expect(second.changeSummary).toContain("Open PRs: 1 → 2 (+1)");
|
||||
expect(second.handoffText).toContain(
|
||||
"What changed since the previous run:",
|
||||
);
|
||||
expect(second.handoffText).toContain("Open PRs: 1 → 2 (+1)");
|
||||
});
|
||||
|
||||
it("treats an unreadable state file as empty state", async () => {
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => ({ version: 1 }),
|
||||
writeState: () => undefined,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(result.handoffText).toContain("# GitHub PR Dashboard");
|
||||
});
|
||||
|
||||
it("does not mark a changed snapshot as applied until explicitly promoted", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(state.lastSnapshotHash).toBeUndefined();
|
||||
expect(state.pendingSnapshotHash).toBe(result.snapshotHash);
|
||||
expect(
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: result.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(state.lastSnapshotHash).toBe(result.snapshotHash);
|
||||
expect(state.pendingSnapshotHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fetchGitHubPrDashboardData,
|
||||
normalizePullRequest,
|
||||
normalizeReview,
|
||||
} from "../github";
|
||||
|
||||
describe("github PR dashboard GitHub client", () => {
|
||||
it("normalizes pull requests", () => {
|
||||
expect(
|
||||
normalizePullRequest("cline/cline", {
|
||||
number: 12,
|
||||
title: "Dashboard",
|
||||
html_url: "https://github.com/cline/cline/pull/12",
|
||||
state: "open",
|
||||
draft: false,
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
requested_reviewers: [{ login: "amy" }],
|
||||
requested_teams: [{ slug: "platform" }],
|
||||
}),
|
||||
).toEqual({
|
||||
number: 12,
|
||||
title: "Dashboard",
|
||||
url: "https://github.com/cline/cline/pull/12",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-02T00:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: ["platform"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes reviews", () => {
|
||||
expect(
|
||||
normalizeReview("cline/cline", 12, {
|
||||
user: { login: "amy" },
|
||||
state: "APPROVED",
|
||||
submitted_at: "2026-06-02T00:00:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-02T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches open pulls separately so open counts are not limited by recent activity", async () => {
|
||||
const urls: string[] = [];
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_TOKEN: "token-1",
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS: "5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("/reviews")) {
|
||||
return [
|
||||
{
|
||||
user: { login: "amy" },
|
||||
state: "APPROVED",
|
||||
submitted_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (url.includes("state=open")) {
|
||||
return [
|
||||
{
|
||||
number: 1,
|
||||
title: "Open PR 1",
|
||||
state: "open",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Open PR 2",
|
||||
state: "open",
|
||||
user: { login: "amy" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-01T12:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (url.includes("state=closed")) {
|
||||
return [
|
||||
{
|
||||
number: 3,
|
||||
title: "Recently closed PR",
|
||||
state: "closed",
|
||||
user: { login: "sam" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
closed_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
number: 4,
|
||||
title: "Recent activity PR",
|
||||
state: "open",
|
||||
user: { login: "lee" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.pullsByRepo["cline/cline"]?.map((pull) => pull.number),
|
||||
).toEqual([4, 3, 1, 2]);
|
||||
expect(result.reviewsByRepo["cline/cline"]?.[0]?.reviewer).toBe("amy");
|
||||
expect(urls.some((url) => url.includes("state=open"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("state=closed"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("state=all"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("/pulls/4/reviews"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("/pulls/1/reviews"))).toBe(false);
|
||||
});
|
||||
|
||||
it("caps open PR pagination and returns a warning when the cap is reached", async () => {
|
||||
const urls: string[] = [];
|
||||
const fullPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `Open PR ${index + 1}`,
|
||||
state: "open",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
}));
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES: "2",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("state=open")) return fullPage;
|
||||
return [];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.pullsByRepo["cline/cline"]).toHaveLength(100);
|
||||
expect(
|
||||
urls
|
||||
.filter((url) => url.includes("state=open"))
|
||||
.map((url) => new URL(url).searchParams.get("page")),
|
||||
).toEqual(["1", "2"]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
repository: "cline/cline",
|
||||
type: "open-pr-page-limit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps recently closed pagination and returns a warning when the cap is reached", async () => {
|
||||
const urls: string[] = [];
|
||||
const fullClosedPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `Closed PR ${index + 1}`,
|
||||
state: "closed",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
closed_at: "2026-06-03T00:00:00Z",
|
||||
}));
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES: "2",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("state=closed")) return fullClosedPage;
|
||||
return [];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.pullsByRepo["cline/cline"]).toHaveLength(100);
|
||||
expect(
|
||||
urls
|
||||
.filter((url) => url.includes("state=closed"))
|
||||
.map((url) => new URL(url).searchParams.get("page")),
|
||||
).toEqual(["1", "2"]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
repository: "cline/cline",
|
||||
type: "closed-pr-page-limit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDashboardHtml, summarizeDashboardChanges } from "../format";
|
||||
import { buildDashboardSnapshot, hashDashboardSnapshot } from "../metrics";
|
||||
|
||||
describe("github PR dashboard metrics", () => {
|
||||
it("computes summary, waiting list, trends, authors, and reviewers", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 48,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 3,
|
||||
pullsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
number: 1,
|
||||
title: "Open waiting",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-09T00:00:00Z",
|
||||
updatedAt: "2026-06-09T12:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Merged",
|
||||
url: "https://github.com/cline/cline/pull/2",
|
||||
state: "closed",
|
||||
draft: false,
|
||||
author: "sam",
|
||||
createdAt: "2026-06-08T00:00:00Z",
|
||||
updatedAt: "2026-06-09T00:00:00Z",
|
||||
closedAt: "2026-06-09T01:00:00Z",
|
||||
mergedAt: "2026-06-09T01:00:00Z",
|
||||
requestedReviewers: [],
|
||||
requestedTeams: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
reviewsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 2,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:30:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.summary.openCount).toBe(1);
|
||||
expect(snapshot.summary.newOpenCount).toBe(1);
|
||||
expect(snapshot.summary.recentlyClosedCount).toBe(1);
|
||||
expect(snapshot.waitingForReview[0]?.waitingHours).toBe(24);
|
||||
expect(snapshot.leadingAuthors.week).toEqual([
|
||||
{ login: "john", count: 1 },
|
||||
{ login: "sam", count: 1 },
|
||||
]);
|
||||
expect(snapshot.leadingReviewers.week).toEqual([
|
||||
{ login: "amy", count: 1 },
|
||||
]);
|
||||
expect(snapshot.volumeTrend.at(-2)).toEqual({
|
||||
date: "2026-06-09",
|
||||
opened: 1,
|
||||
closed: 1,
|
||||
merged: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("de-duplicates reviewer counts per repository and PR number", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline", "cline/sdk"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [], "cline/sdk": [] },
|
||||
reviewsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:00:00Z",
|
||||
},
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2026-06-09T01:00:00Z",
|
||||
},
|
||||
],
|
||||
"cline/sdk": [
|
||||
{
|
||||
repository: "cline/sdk",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.leadingReviewers.week).toEqual([
|
||||
{ login: "amy", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hash ignores generatedAt and time-derived age fields", () => {
|
||||
const base = {
|
||||
generatedAt: "2026-06-10T00:00:00Z",
|
||||
repositories: ["cline/cline"],
|
||||
window: { newPrHours: 24, recentlyClosedDays: 7, trendDays: 1 },
|
||||
summary: {
|
||||
openCount: 0,
|
||||
newOpenCount: 0,
|
||||
recentlyClosedCount: 0,
|
||||
avgOpenAgeHours: 1,
|
||||
avgWaitingForReviewHours: 2,
|
||||
},
|
||||
waitingForReview: [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
number: 1,
|
||||
title: "Waiting",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
author: "john",
|
||||
waitingHours: 3,
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
},
|
||||
],
|
||||
volumeTrend: [{ date: "2026-06-10", opened: 0, closed: 0, merged: 0 }],
|
||||
leadingAuthors: { week: [], month: [] },
|
||||
leadingReviewers: { week: [], month: [] },
|
||||
repositoryBreakdown: [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
openCount: 0,
|
||||
newOpenCount: 0,
|
||||
recentlyClosedCount: 0,
|
||||
avgOpenAgeHours: 4,
|
||||
avgWaitingForReviewHours: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hashDashboardSnapshot(base)).toBe(
|
||||
hashDashboardSnapshot({
|
||||
...base,
|
||||
generatedAt: "2026-06-11T00:00:00Z",
|
||||
summary: {
|
||||
...base.summary,
|
||||
avgOpenAgeHours: 10,
|
||||
avgWaitingForReviewHours: 20,
|
||||
},
|
||||
waitingForReview: base.waitingForReview.map((pull) => ({
|
||||
...pull,
|
||||
waitingHours: 30,
|
||||
})),
|
||||
repositoryBreakdown: base.repositoryBreakdown.map((repository) => ({
|
||||
...repository,
|
||||
avgOpenAgeHours: 40,
|
||||
avgWaitingForReviewHours: 50,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a standalone HTML dashboard", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
const html = renderDashboardHtml(snapshot);
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("GitHub PR Dashboard");
|
||||
expect(html).toContain("cline/cline");
|
||||
expect(html).not.toContain("Checkpoint:");
|
||||
});
|
||||
|
||||
it("renders checkpoint status in the standalone HTML dashboard", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
const html = renderDashboardHtml(snapshot, {
|
||||
checkpointStatus: "unchanged",
|
||||
checkpointReason: "no GitHub PR dashboard changes, exiting",
|
||||
snapshotHash: "abcdef1234567890",
|
||||
});
|
||||
|
||||
expect(html).toContain("Checkpoint: no dashboard changes");
|
||||
expect(html).toContain("no GitHub PR dashboard changes, exiting");
|
||||
expect(html).toContain("abcdef123456");
|
||||
});
|
||||
|
||||
it("summarizes dashboard deltas from a previous snapshot", () => {
|
||||
const previous = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
const current = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
number: 1,
|
||||
title: "New dashboard PR",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-10T00:00:00Z",
|
||||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
expect(summarizeDashboardChanges(previous, current)).toContain(
|
||||
"Open PRs: 0 → 1 (+1)",
|
||||
);
|
||||
expect(summarizeDashboardChanges(previous, current)).toContain(
|
||||
"Newly waiting for review: cline/cline#1 New dashboard PR",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDashboardHandoffKey } from "../index";
|
||||
|
||||
describe("github PR dashboard plugin", () => {
|
||||
it("uses runtime identifiers for pending handoff keys without a shared default", () => {
|
||||
expect(
|
||||
resolveDashboardHandoffKey({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
runId: "run-1",
|
||||
}),
|
||||
).toBe("run-1");
|
||||
expect(
|
||||
resolveDashboardHandoffKey({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
}),
|
||||
).toBe("conversation-1");
|
||||
expect(resolveDashboardHandoffKey({ agentId: "agent-1" })).toBe("agent-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { AgentMessage } from "@cline/shared";
|
||||
import type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
|
||||
export interface DashboardHtmlRenderOptions {
|
||||
checkpointStatus?: "changed" | "unchanged";
|
||||
checkpointReason?: string;
|
||||
snapshotHash?: string;
|
||||
changeSummary?: string[];
|
||||
}
|
||||
|
||||
function tableRows(rows: string[][]): string {
|
||||
return rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
|
||||
}
|
||||
|
||||
export function renderDashboardMarkdown(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
): string {
|
||||
return [
|
||||
"# GitHub PR Dashboard",
|
||||
"",
|
||||
`Generated: ${snapshot.generatedAt}`,
|
||||
`Repositories: ${snapshot.repositories.join(", ")}`,
|
||||
"",
|
||||
"## Summary",
|
||||
tableRows([
|
||||
["Metric", "Value"],
|
||||
["Open PRs", String(snapshot.summary.openCount)],
|
||||
[
|
||||
`New open PRs (${snapshot.window.newPrHours}h)`,
|
||||
String(snapshot.summary.newOpenCount),
|
||||
],
|
||||
[
|
||||
`Recently closed (${snapshot.window.recentlyClosedDays}d)`,
|
||||
String(snapshot.summary.recentlyClosedCount),
|
||||
],
|
||||
["Average open age", `${snapshot.summary.avgOpenAgeHours}h`],
|
||||
[
|
||||
"Average waiting for review",
|
||||
`${snapshot.summary.avgWaitingForReviewHours}h`,
|
||||
],
|
||||
]),
|
||||
"",
|
||||
"## Waiting for Review",
|
||||
...(snapshot.waitingForReview.length > 0
|
||||
? [
|
||||
tableRows([
|
||||
["PR", "Title", "Author", "Waiting", "Requested"],
|
||||
...snapshot.waitingForReview.map((pr) => [
|
||||
`[${pr.repository}#${pr.number}](${pr.url})`,
|
||||
pr.title.replaceAll("|", "\\|"),
|
||||
pr.author,
|
||||
`${pr.waitingHours}h`,
|
||||
[...pr.requestedReviewers, ...pr.requestedTeams].join(", ") ||
|
||||
"—",
|
||||
]),
|
||||
]),
|
||||
]
|
||||
: ["No open PRs are currently waiting for requested reviewers."]),
|
||||
"",
|
||||
"## Volume Trend",
|
||||
tableRows([
|
||||
["Date", "Opened", "Closed", "Merged"],
|
||||
...snapshot.volumeTrend.map((day) => [
|
||||
day.date,
|
||||
String(day.opened),
|
||||
String(day.closed),
|
||||
String(day.merged),
|
||||
]),
|
||||
]),
|
||||
"",
|
||||
"## Leading Authors",
|
||||
"### This Week",
|
||||
snapshot.leadingAuthors.week
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"### This Month",
|
||||
snapshot.leadingAuthors.month
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"",
|
||||
"## Leading Reviewers",
|
||||
"### This Week",
|
||||
snapshot.leadingReviewers.week
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"### This Month",
|
||||
snapshot.leadingReviewers.month
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"",
|
||||
"## Repository Breakdown",
|
||||
tableRows([
|
||||
[
|
||||
"Repository",
|
||||
"Open",
|
||||
"New",
|
||||
"Recently Closed",
|
||||
"Avg Open Age",
|
||||
"Avg Review Wait",
|
||||
],
|
||||
...snapshot.repositoryBreakdown.map((repo) => [
|
||||
repo.repository,
|
||||
String(repo.openCount),
|
||||
String(repo.newOpenCount),
|
||||
String(repo.recentlyClosedCount),
|
||||
`${repo.avgOpenAgeHours}h`,
|
||||
`${repo.avgWaitingForReviewHours}h`,
|
||||
]),
|
||||
]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function signedDelta(current: number, previous: number): string {
|
||||
const delta = current - previous;
|
||||
if (delta === 0) return "no change";
|
||||
return `${previous} → ${current} (${delta > 0 ? "+" : ""}${delta})`;
|
||||
}
|
||||
|
||||
function itemKey(item: { repository: string; number: number }): string {
|
||||
return `${item.repository}#${item.number}`;
|
||||
}
|
||||
|
||||
function topLogin(items: Array<{ login: string; count: number }>): string {
|
||||
const item = items[0];
|
||||
return item ? `${item.login} (${item.count})` : "none";
|
||||
}
|
||||
|
||||
function summarizeWaitingChanges(
|
||||
previous: GitHubPrDashboardSnapshot,
|
||||
current: GitHubPrDashboardSnapshot,
|
||||
): string[] {
|
||||
const previousWaiting = new Map(
|
||||
previous.waitingForReview.map((item) => [itemKey(item), item]),
|
||||
);
|
||||
const currentWaiting = new Map(
|
||||
current.waitingForReview.map((item) => [itemKey(item), item]),
|
||||
);
|
||||
const newlyWaiting = [...currentWaiting.entries()]
|
||||
.filter(([key]) => !previousWaiting.has(key))
|
||||
.slice(0, 5)
|
||||
.map(([key, item]) => `${key} ${item.title}`);
|
||||
const noLongerWaiting = [...previousWaiting.entries()]
|
||||
.filter(([key]) => !currentWaiting.has(key))
|
||||
.slice(0, 5)
|
||||
.map(([key, item]) => `${key} ${item.title}`);
|
||||
|
||||
return [
|
||||
...(newlyWaiting.length > 0
|
||||
? [`Newly waiting for review: ${newlyWaiting.join("; ")}`]
|
||||
: []),
|
||||
...(noLongerWaiting.length > 0
|
||||
? [`No longer waiting for review: ${noLongerWaiting.join("; ")}`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function summarizeDashboardChanges(
|
||||
previous: GitHubPrDashboardSnapshot | undefined,
|
||||
current: GitHubPrDashboardSnapshot,
|
||||
): string[] {
|
||||
if (!previous) {
|
||||
return [
|
||||
"Initial dashboard snapshot captured; future runs will summarize changes from this baseline.",
|
||||
];
|
||||
}
|
||||
|
||||
const changes = [
|
||||
`Open PRs: ${signedDelta(current.summary.openCount, previous.summary.openCount)}`,
|
||||
`New open PRs: ${signedDelta(current.summary.newOpenCount, previous.summary.newOpenCount)}`,
|
||||
`Recently closed PRs: ${signedDelta(current.summary.recentlyClosedCount, previous.summary.recentlyClosedCount)}`,
|
||||
...summarizeWaitingChanges(previous, current),
|
||||
];
|
||||
|
||||
const previousTopAuthor = topLogin(previous.leadingAuthors.week);
|
||||
const currentTopAuthor = topLogin(current.leadingAuthors.week);
|
||||
if (previousTopAuthor !== currentTopAuthor) {
|
||||
changes.push(
|
||||
`Top author this week: ${previousTopAuthor} → ${currentTopAuthor}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previousTopReviewer = topLogin(previous.leadingReviewers.week);
|
||||
const currentTopReviewer = topLogin(current.leadingReviewers.week);
|
||||
if (previousTopReviewer !== currentTopReviewer) {
|
||||
changes.push(
|
||||
`Top reviewer this week: ${previousTopReviewer} → ${currentTopReviewer}`,
|
||||
);
|
||||
}
|
||||
|
||||
return changes.filter((change) => !change.endsWith("no change"));
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function htmlTable(headers: string[], rows: string[][]): string {
|
||||
return [
|
||||
'<div class="table-wrap"><table>',
|
||||
`<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>`,
|
||||
`<tbody>${rows
|
||||
.map(
|
||||
(row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`,
|
||||
)
|
||||
.join("")}</tbody>`,
|
||||
"</table></div>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function metricCard(label: string, value: string): string {
|
||||
return `<section class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></section>`;
|
||||
}
|
||||
|
||||
function topList(items: Array<{ login: string; count: number }>): string {
|
||||
if (items.length === 0) return '<p class="muted">none</p>';
|
||||
return `<ol>${items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li><span>${escapeHtml(item.login)}</span><strong>${item.count}</strong></li>`,
|
||||
)
|
||||
.join("")}</ol>`;
|
||||
}
|
||||
|
||||
export function renderDashboardHtml(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
options: DashboardHtmlRenderOptions = {},
|
||||
): string {
|
||||
const waitingRows = snapshot.waitingForReview.map((pr) => [
|
||||
`<a href="${escapeHtml(pr.url)}">${escapeHtml(`${pr.repository}#${pr.number}`)}</a>`,
|
||||
escapeHtml(pr.title),
|
||||
escapeHtml(pr.author),
|
||||
escapeHtml(`${pr.waitingHours}h`),
|
||||
escapeHtml(
|
||||
[...pr.requestedReviewers, ...pr.requestedTeams].join(", ") || "—",
|
||||
),
|
||||
]);
|
||||
const trendRows = snapshot.volumeTrend.map((day) => [
|
||||
escapeHtml(day.date),
|
||||
escapeHtml(String(day.opened)),
|
||||
escapeHtml(String(day.closed)),
|
||||
escapeHtml(String(day.merged)),
|
||||
]);
|
||||
const repoRows = snapshot.repositoryBreakdown.map((repo) => [
|
||||
escapeHtml(repo.repository),
|
||||
escapeHtml(String(repo.openCount)),
|
||||
escapeHtml(String(repo.newOpenCount)),
|
||||
escapeHtml(String(repo.recentlyClosedCount)),
|
||||
escapeHtml(`${repo.avgOpenAgeHours}h`),
|
||||
escapeHtml(`${repo.avgWaitingForReviewHours}h`),
|
||||
]);
|
||||
|
||||
const repositoryPills = snapshot.repositories
|
||||
.map(
|
||||
(repository) =>
|
||||
`<span class="repo-pill">${escapeHtml(repository)}</span>`,
|
||||
)
|
||||
.join("");
|
||||
const checkpointBanner = options.checkpointStatus
|
||||
? (() => {
|
||||
const checkpointChanged = options.checkpointStatus === "changed";
|
||||
const checkpointTitle = checkpointChanged
|
||||
? "Checkpoint: dashboard data changed"
|
||||
: "Checkpoint: no dashboard changes";
|
||||
const checkpointDescription =
|
||||
options.checkpointReason ??
|
||||
(checkpointChanged
|
||||
? "The gate found a new dashboard snapshot and would wake the agent."
|
||||
: "The gate matched the previous applied snapshot and skipped the agent run.");
|
||||
const checkpointChanges = options.changeSummary?.length
|
||||
? `<ul class="checkpoint-list">${options.changeSummary.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
|
||||
: "";
|
||||
const checkpointHash = options.snapshotHash
|
||||
? `<code>${escapeHtml(options.snapshotHash.slice(0, 12))}</code>`
|
||||
: "";
|
||||
return `<section class="checkpoint ${checkpointChanged ? "checkpoint-changed" : "checkpoint-unchanged"}">
|
||||
<div><span class="checkpoint-kicker">${checkpointChanged ? "Agent wake" : "Checkpoint hit"}</span><h2>${checkpointTitle}</h2><p>${escapeHtml(checkpointDescription)}</p>${checkpointChanges}</div>
|
||||
<div class="checkpoint-hash"><span>Snapshot</span>${checkpointHash}</div>
|
||||
</section>`;
|
||||
})()
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GitHub PR Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: rgba(24, 24, 27, 0.86);
|
||||
--card-strong: rgba(39, 39, 42, 0.88);
|
||||
--muted: #a1a1aa;
|
||||
--muted-strong: #d4d4d8;
|
||||
--divider: rgba(255, 255, 255, 0.10);
|
||||
--divider-strong: rgba(255, 255, 255, 0.16);
|
||||
--purple: #c084fc;
|
||||
--purple-strong: #a855f7;
|
||||
--fuchsia: #e879f9;
|
||||
--emerald: #34d399;
|
||||
--amber: #fbbf24;
|
||||
--radius-card: 14px;
|
||||
--shadow-card: 0 18px 70px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: normal;
|
||||
background:
|
||||
radial-gradient(circle at 18% 10%, rgba(168, 85, 247, 0.24), transparent 34rem),
|
||||
radial-gradient(circle at 88% 4%, rgba(217, 70, 239, 0.16), transparent 28rem),
|
||||
linear-gradient(180deg, #111113 0%, var(--background) 46%, #050506 100%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 40px 24px 56px; position: relative; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 0.95; letter-spacing: -0.055em; margin: 0; }
|
||||
h2 { font-size: 1rem; line-height: 1.1; letter-spacing: -0.02em; margin: 0; }
|
||||
h3 { color: var(--muted-strong); font-size: 0.8rem; letter-spacing: 0.06em; margin: 18px 0 10px; text-transform: uppercase; }
|
||||
a { color: #d8b4fe; font-weight: 650; text-decoration: none; }
|
||||
a:hover { color: white; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.muted { color: var(--muted); }
|
||||
.eyebrow { align-items: center; background: rgba(251, 191, 36, 0.16); border: 1px solid rgba(251, 191, 36, 0.26); border-radius: 999px; color: #fcd34d; display: inline-flex; font-size: 0.72rem; font-weight: 800; gap: 7px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; width: fit-content; }
|
||||
.eyebrow::before { content: "✦"; color: var(--amber); }
|
||||
.hero { background: radial-gradient(circle at 18% 18%, rgba(168,85,247,0.24), transparent 62%), linear-gradient(135deg, rgba(88,28,135,0.38), rgba(76,29,149,0.24) 56%, rgba(17,24,39,0.34)); border: 1px solid var(--divider); border-radius: 24px; box-shadow: var(--shadow-card); overflow: hidden; padding: clamp(24px, 5vw, 42px); position: relative; }
|
||||
.hero::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(255,255,255,0.12), transparent 32%, rgba(255,255,255,0.04)); }
|
||||
.hero-content { display: grid; gap: 26px; position: relative; z-index: 1; }
|
||||
.hero-top { display: flex; flex-wrap: wrap; gap: 18px; justify-content: space-between; }
|
||||
.subtitle { color: var(--muted); font-size: 1rem; line-height: 1.65; margin: 18px 0 0; max-width: 760px; }
|
||||
.repo-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.repo-pill { background: rgba(255, 255, 255, 0.06); border: 1px solid var(--divider); border-radius: 999px; color: var(--muted-strong); font-size: 0.78rem; font-weight: 700; padding: 7px 10px; }
|
||||
.timestamp { color: var(--muted); font-size: 0.82rem; margin: 0; text-align: right; }
|
||||
.checkpoint { align-items: center; border: 1px solid var(--divider); border-radius: var(--radius-card); display: flex; gap: 18px; justify-content: space-between; margin-top: 18px; padding: 18px 20px; }
|
||||
.checkpoint-unchanged { background: linear-gradient(135deg, rgba(16,185,129,0.20), rgba(20,184,166,0.10) 52%, rgba(24,24,27,0.78)); border-color: rgba(52,211,153,0.28); }
|
||||
.checkpoint-changed { background: linear-gradient(135deg, rgba(168,85,247,0.24), rgba(232,121,249,0.10) 52%, rgba(24,24,27,0.78)); border-color: rgba(192,132,252,0.32); }
|
||||
.checkpoint-kicker { color: var(--emerald); display: block; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.14em; margin-bottom: 8px; text-transform: uppercase; }
|
||||
.checkpoint-changed .checkpoint-kicker { color: #f0abfc; }
|
||||
.checkpoint p { color: var(--muted); font-size: 0.9rem; line-height: 1.55; margin: 8px 0 0; max-width: 700px; }
|
||||
.checkpoint-list { color: var(--muted-strong); font-size: 0.85rem; margin: 10px 0 0; padding-left: 18px; }
|
||||
.checkpoint-hash { align-items: flex-end; display: flex; flex-direction: column; gap: 6px; white-space: nowrap; }
|
||||
.checkpoint-hash span { color: var(--muted); font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.checkpoint-hash code { background: rgba(0,0,0,0.24); border: 1px solid var(--divider); border-radius: 8px; color: var(--muted-strong); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.82rem; padding: 6px 8px; }
|
||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.metric, .panel, .section-card { background: var(--card); border: 1px solid var(--divider); border-radius: var(--radius-card); box-shadow: 0 12px 42px rgba(0,0,0,0.22); }
|
||||
.metric { min-height: 132px; padding: 20px; position: relative; overflow: hidden; }
|
||||
.metric::after { background: linear-gradient(135deg, rgba(168,85,247,0.18), rgba(232,121,249,0.08)); border-radius: 999px; content: ""; height: 92px; position: absolute; right: -32px; top: -34px; width: 92px; }
|
||||
.metric span { color: var(--muted); display: block; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; margin-bottom: 14px; max-width: 150px; text-transform: uppercase; }
|
||||
.metric strong { display: block; font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 750; letter-spacing: -0.06em; line-height: 0.95; }
|
||||
.section-card { margin-top: 18px; overflow: hidden; }
|
||||
.section-header { align-items: center; border-bottom: 1px solid var(--divider); display: flex; justify-content: space-between; min-height: 68px; padding: 20px 24px 16px; }
|
||||
.section-body { padding: 0; }
|
||||
.panel { padding: 22px 24px; }
|
||||
.panel h2 { margin-bottom: 12px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border-bottom: 1px solid var(--divider); padding: 14px 16px; text-align: left; vertical-align: top; }
|
||||
th { background: rgba(255,255,255,0.035); color: var(--muted); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; }
|
||||
td { color: var(--muted-strong); font-size: 0.9rem; }
|
||||
tbody tr:hover { background: rgba(255,255,255,0.035); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
ol { margin: 0; padding-left: 22px; }
|
||||
li { color: var(--muted-strong); margin: 8px 0; }
|
||||
li strong { background: rgba(168,85,247,0.16); border: 1px solid rgba(168,85,247,0.22); border-radius: 999px; color: #e9d5ff; margin-left: 8px; padding: 2px 8px; }
|
||||
.panel-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin-top: 18px; }
|
||||
.empty { padding: 22px 24px; }
|
||||
section.dashboard-section { margin-top: 28px; }
|
||||
@media (max-width: 720px) {
|
||||
main { padding: 22px 14px 36px; }
|
||||
.hero { border-radius: 18px; }
|
||||
.timestamp { text-align: left; }
|
||||
.checkpoint { align-items: flex-start; flex-direction: column; }
|
||||
.checkpoint-hash { align-items: flex-start; }
|
||||
.section-header { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
th, td { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-top">
|
||||
<span class="eyebrow">Cline PR Intelligence</span>
|
||||
<p class="timestamp">Generated ${escapeHtml(snapshot.generatedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1>GitHub PR Dashboard</h1>
|
||||
<p class="subtitle">A scheduled Cline dashboard for review load, PR velocity, and repository health. Metrics are generated by the deterministic before-run gate and styled after the Cline dashboard UI.</p>
|
||||
</div>
|
||||
<div class="repo-list">${repositoryPills}</div>
|
||||
<div class="grid">
|
||||
${metricCard("Open PRs", String(snapshot.summary.openCount))}
|
||||
${metricCard(`New open PRs (${snapshot.window.newPrHours}h)`, String(snapshot.summary.newOpenCount))}
|
||||
${metricCard(`Recently closed (${snapshot.window.recentlyClosedDays}d)`, String(snapshot.summary.recentlyClosedCount))}
|
||||
${metricCard("Avg review wait", `${snapshot.summary.avgWaitingForReviewHours}h`)}
|
||||
</div>
|
||||
${checkpointBanner}
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Waiting for Review</h2><span class="muted">${snapshot.waitingForReview.length} PRs</span></div><div class="section-body">${
|
||||
waitingRows.length > 0
|
||||
? htmlTable(
|
||||
["PR", "Title", "Author", "Waiting", "Requested"],
|
||||
waitingRows,
|
||||
)
|
||||
: '<p class="muted empty">No open PRs are currently waiting for requested reviewers.</p>'
|
||||
}</div></section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Volume Trend</h2><span class="muted">Last ${snapshot.window.trendDays} days</span></div><div class="section-body">${htmlTable(["Date", "Opened", "Closed", "Merged"], trendRows)}</div></section>
|
||||
<section class="panel-grid">
|
||||
<div class="panel"><h2>Leading Authors</h2><h3>This Week</h3>${topList(snapshot.leadingAuthors.week)}<h3>This Month</h3>${topList(snapshot.leadingAuthors.month)}</div>
|
||||
<div class="panel"><h2>Leading Reviewers</h2><h3>This Week</h3>${topList(snapshot.leadingReviewers.week)}<h3>This Month</h3>${topList(snapshot.leadingReviewers.month)}</div>
|
||||
</section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Repository Breakdown</h2><span class="muted">${snapshot.repositories.length} repositories</span></div><div class="section-body">${htmlTable(["Repository", "Open", "New", "Recently Closed", "Avg Open Age", "Avg Review Wait"], repoRows)}</div></section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function formatDashboardHandoff(run: GitHubPrDashboardRun): string {
|
||||
return [
|
||||
"GitHub PR dashboard gate found changed dashboard data.",
|
||||
`Run ID: ${run.runId}`,
|
||||
`Snapshot hash: ${run.snapshotHash}`,
|
||||
`Dashboard path to update: ${run.dashboardPath}`,
|
||||
"",
|
||||
"What changed since the previous run:",
|
||||
...(run.changeSummary.length > 0
|
||||
? run.changeSummary.map((item) => `- ${item}`)
|
||||
: [
|
||||
"- Dashboard data changed, but no high-level summary fields changed.",
|
||||
]),
|
||||
"",
|
||||
"Task:",
|
||||
"1. Update the dashboard file at the exact path above with the Markdown dashboard below.",
|
||||
"2. Keep the update focused on the dashboard file only.",
|
||||
"3. Briefly summarize what changed in the PR metrics after writing the file.",
|
||||
"4. Do not edit unrelated files.",
|
||||
"",
|
||||
"# Dashboard Markdown",
|
||||
"```md",
|
||||
renderDashboardMarkdown(run.snapshot),
|
||||
"```",
|
||||
"",
|
||||
"# Raw Snapshot JSON",
|
||||
"```json",
|
||||
JSON.stringify(run.snapshot, null, 2),
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function makeDashboardHandoffMessage(text: string): AgentMessage {
|
||||
const createdAt = Date.now();
|
||||
return {
|
||||
id: `msg_github_pr_dashboard_${createdAt}`,
|
||||
role: "user",
|
||||
createdAt,
|
||||
content: [{ type: "text", text }],
|
||||
metadata: { source: "github-pr-dashboard" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { BasicLogger } from "@cline/core";
|
||||
import { formatDashboardHandoff, summarizeDashboardChanges } from "./format";
|
||||
import {
|
||||
type FetchJson,
|
||||
fetchGitHubPrDashboardData,
|
||||
type GitHubPrDashboardDataWarning,
|
||||
} from "./github";
|
||||
import { buildDashboardSnapshot, hashDashboardSnapshot } from "./metrics";
|
||||
import type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
import {
|
||||
type GitHubPrDashboardState,
|
||||
markSnapshotApplied,
|
||||
readState,
|
||||
resolveStatePath,
|
||||
writeState,
|
||||
} from "./state";
|
||||
|
||||
export interface GitHubPrDashboardGateOptions {
|
||||
logger?: BasicLogger;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readState?: () => GitHubPrDashboardState;
|
||||
writeState?: (state: GitHubPrDashboardState) => void;
|
||||
fetchJson?: FetchJson;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardGateResult {
|
||||
stop?: boolean;
|
||||
reason: string;
|
||||
snapshot?: GitHubPrDashboardSnapshot;
|
||||
snapshotHash?: string;
|
||||
dashboardPath?: string;
|
||||
handoffText?: string;
|
||||
changeSummary?: string[];
|
||||
run?: GitHubPrDashboardRun;
|
||||
statePath?: string;
|
||||
warnings?: GitHubPrDashboardDataWarning[];
|
||||
}
|
||||
|
||||
export interface ApplyGitHubPrDashboardSnapshotOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
statePath?: string;
|
||||
snapshotHash: string;
|
||||
readState?: () => GitHubPrDashboardState;
|
||||
writeState?: (state: GitHubPrDashboardState) => void;
|
||||
}
|
||||
|
||||
function log(
|
||||
logger: BasicLogger | undefined,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void {
|
||||
logger?.log?.(message, metadata);
|
||||
}
|
||||
|
||||
export async function runGitHubPrDashboardGate(
|
||||
options: GitHubPrDashboardGateOptions = {},
|
||||
): Promise<GitHubPrDashboardGateResult> {
|
||||
const generatedAt = (options.now?.() ?? new Date()).toISOString();
|
||||
const statePath = resolveStatePath(options.env ?? process.env);
|
||||
const persistState =
|
||||
options.writeState ??
|
||||
((nextState: GitHubPrDashboardState) => writeState(nextState, statePath));
|
||||
const state = options.readState?.() ?? readState(statePath);
|
||||
const data = await fetchGitHubPrDashboardData({
|
||||
env: options.env,
|
||||
fetchJson: options.fetchJson,
|
||||
now: new Date(generatedAt),
|
||||
});
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date(generatedAt),
|
||||
repositories: data.config.repositories,
|
||||
pullsByRepo: data.pullsByRepo,
|
||||
reviewsByRepo: data.reviewsByRepo,
|
||||
newPrHours: data.config.newPrHours,
|
||||
recentlyClosedDays: data.config.recentlyClosedDays,
|
||||
trendDays: data.config.trendDays,
|
||||
});
|
||||
const snapshotHash = hashDashboardSnapshot(snapshot);
|
||||
const changeSummary = summarizeDashboardChanges(state.lastSnapshot, snapshot);
|
||||
|
||||
const hasAppliedSnapshot = state.lastSnapshotHash === snapshotHash;
|
||||
const hasPendingSnapshot = state.pendingSnapshotHash === snapshotHash;
|
||||
|
||||
if (hasAppliedSnapshot || hasPendingSnapshot) {
|
||||
persistState({
|
||||
version: 1,
|
||||
...(hasAppliedSnapshot
|
||||
? { lastSnapshotHash: snapshotHash }
|
||||
: state.lastSnapshotHash
|
||||
? { lastSnapshotHash: state.lastSnapshotHash }
|
||||
: {}),
|
||||
...(hasAppliedSnapshot
|
||||
? { lastGeneratedAt: generatedAt }
|
||||
: state.lastGeneratedAt
|
||||
? { lastGeneratedAt: state.lastGeneratedAt }
|
||||
: {}),
|
||||
...(hasAppliedSnapshot
|
||||
? { lastSnapshot: snapshot }
|
||||
: state.lastSnapshot
|
||||
? { lastSnapshot: state.lastSnapshot }
|
||||
: {}),
|
||||
...(state.pendingSnapshotHash
|
||||
? { pendingSnapshotHash: state.pendingSnapshotHash }
|
||||
: {}),
|
||||
...(state.pendingGeneratedAt
|
||||
? { pendingGeneratedAt: state.pendingGeneratedAt }
|
||||
: {}),
|
||||
...(state.pendingSnapshot
|
||||
? { pendingSnapshot: state.pendingSnapshot }
|
||||
: {}),
|
||||
});
|
||||
log(options.logger, "github-pr-dashboard: no dashboard changes, exiting", {
|
||||
repositories: data.config.repositories,
|
||||
snapshotHash,
|
||||
warnings: data.warnings,
|
||||
});
|
||||
return {
|
||||
stop: true,
|
||||
reason: "no GitHub PR dashboard changes, exiting",
|
||||
snapshot,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
changeSummary: [],
|
||||
statePath,
|
||||
warnings: data.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
persistState({
|
||||
version: 1,
|
||||
...(state.lastSnapshotHash
|
||||
? { lastSnapshotHash: state.lastSnapshotHash }
|
||||
: {}),
|
||||
...(state.lastGeneratedAt
|
||||
? { lastGeneratedAt: state.lastGeneratedAt }
|
||||
: {}),
|
||||
...(state.lastSnapshot ? { lastSnapshot: state.lastSnapshot } : {}),
|
||||
pendingSnapshotHash: snapshotHash,
|
||||
pendingGeneratedAt: generatedAt,
|
||||
pendingSnapshot: snapshot,
|
||||
});
|
||||
const run: GitHubPrDashboardRun = {
|
||||
runId: `github-pr-dashboard-${generatedAt}`,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
snapshot,
|
||||
changeSummary,
|
||||
};
|
||||
const handoffText = formatDashboardHandoff(run);
|
||||
log(options.logger, "github-pr-dashboard: dashboard changes found", {
|
||||
repositories: data.config.repositories,
|
||||
snapshotHash,
|
||||
openCount: snapshot.summary.openCount,
|
||||
warnings: data.warnings,
|
||||
});
|
||||
return {
|
||||
reason: "GitHub PR dashboard data changed",
|
||||
snapshot,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
changeSummary,
|
||||
handoffText,
|
||||
run,
|
||||
statePath,
|
||||
warnings: data.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function markGitHubPrDashboardSnapshotApplied(
|
||||
options: ApplyGitHubPrDashboardSnapshotOptions,
|
||||
): boolean {
|
||||
const statePath =
|
||||
options.statePath ?? resolveStatePath(options.env ?? process.env);
|
||||
const currentState = options.readState?.() ?? readState(statePath);
|
||||
const nextState = markSnapshotApplied(currentState, options.snapshotHash);
|
||||
if (nextState === currentState) return false;
|
||||
(
|
||||
options.writeState ??
|
||||
((state: GitHubPrDashboardState) => writeState(state, statePath))
|
||||
)(nextState);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import type {
|
||||
GitHubPullRequestRecord,
|
||||
GitHubPullRequestReviewRecord,
|
||||
} from "./schema";
|
||||
|
||||
export interface GitHubPrDashboardConfig {
|
||||
repositories: string[];
|
||||
maxPullsPerRepo: number;
|
||||
maxOpenPages: number;
|
||||
maxClosedPages: number;
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
dashboardPath: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardDataWarning {
|
||||
repository: string;
|
||||
type: "closed-pr-page-limit" | "open-pr-page-limit";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullApiRecord {
|
||||
number: number;
|
||||
title?: string;
|
||||
html_url?: string;
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
user?: { login?: string | null } | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
closed_at?: string | null;
|
||||
merged_at?: string | null;
|
||||
requested_reviewers?: Array<{ login?: string | null }>;
|
||||
requested_teams?: Array<{ name?: string | null; slug?: string | null }>;
|
||||
}
|
||||
|
||||
export interface GitHubReviewApiRecord {
|
||||
user?: { login?: string | null } | null;
|
||||
state?: string;
|
||||
submitted_at?: string | null;
|
||||
}
|
||||
|
||||
export type FetchJson = (
|
||||
url: string,
|
||||
init: { headers: Record<string, string> },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type PullRequestStateFilter = "open" | "closed" | "all";
|
||||
|
||||
function splitCsv(value: string | undefined): string[] {
|
||||
return value
|
||||
? value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function positiveInt(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
max: number,
|
||||
): number {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? Math.min(Math.trunc(parsed), max)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function resolveGitHubPrDashboardConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): GitHubPrDashboardConfig {
|
||||
const repositories = splitCsv(env.GITHUB_REPOSITORIES);
|
||||
if (repositories.length === 0) {
|
||||
throw new Error(
|
||||
"Set GITHUB_REPOSITORIES=owner/repo[,owner/repo] to use github-pr-dashboard",
|
||||
);
|
||||
}
|
||||
return {
|
||||
repositories,
|
||||
maxPullsPerRepo: positiveInt(env.GITHUB_PR_DASHBOARD_MAX_PRS, 25, 100),
|
||||
maxOpenPages: positiveInt(env.GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES, 10, 50),
|
||||
maxClosedPages: positiveInt(
|
||||
env.GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES,
|
||||
10,
|
||||
50,
|
||||
),
|
||||
newPrHours: positiveInt(env.GITHUB_PR_DASHBOARD_NEW_HOURS, 24, 24 * 30),
|
||||
recentlyClosedDays: positiveInt(
|
||||
env.GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS,
|
||||
7,
|
||||
365,
|
||||
),
|
||||
trendDays: positiveInt(env.GITHUB_PR_DASHBOARD_TREND_DAYS, 14, 365),
|
||||
dashboardPath:
|
||||
env.GITHUB_PR_DASHBOARD_PATH?.trim() || "github-pr-dashboard.md",
|
||||
token: env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultFetchJson(
|
||||
url: string,
|
||||
init: { headers: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API request failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function headersFor(config: GitHubPrDashboardConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/vnd.github+json",
|
||||
"user-agent": "cline-github-pr-dashboard-plugin",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
};
|
||||
if (config.token) headers.authorization = `Bearer ${config.token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function mergePullsByNumber(
|
||||
primary: GitHubPullRequestRecord[],
|
||||
secondary: GitHubPullRequestRecord[],
|
||||
): GitHubPullRequestRecord[] {
|
||||
const merged = new Map<number, GitHubPullRequestRecord>();
|
||||
for (const pull of [...primary, ...secondary]) {
|
||||
merged.set(pull.number, pull);
|
||||
}
|
||||
return [...merged.values()].sort((left, right) => {
|
||||
const rightUpdated = new Date(right.updatedAt).getTime();
|
||||
const leftUpdated = new Date(left.updatedAt).getTime();
|
||||
return rightUpdated - leftUpdated || right.number - left.number;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPullsPage(options: {
|
||||
repository: string;
|
||||
state: PullRequestStateFilter;
|
||||
page: number;
|
||||
perPage: number;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<GitHubPullRequestRecord[]> {
|
||||
const pullsParams = new URLSearchParams({
|
||||
state: options.state,
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: String(options.perPage),
|
||||
page: String(options.page),
|
||||
});
|
||||
const pullsPayload = await options.fetchJson(
|
||||
`https://api.github.com/repos/${options.repository}/pulls?${pullsParams}`,
|
||||
{ headers: headersFor(options.config) },
|
||||
);
|
||||
if (!Array.isArray(pullsPayload)) {
|
||||
throw new Error(
|
||||
`GitHub API returned a non-array pulls payload for ${options.repository}`,
|
||||
);
|
||||
}
|
||||
return (pullsPayload as GitHubPullApiRecord[])
|
||||
.map((pull) => normalizePullRequest(options.repository, pull))
|
||||
.filter((pull): pull is GitHubPullRequestRecord => Boolean(pull));
|
||||
}
|
||||
|
||||
async function fetchAllOpenPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<{
|
||||
pulls: GitHubPullRequestRecord[];
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const perPage = 100;
|
||||
const pulls: GitHubPullRequestRecord[] = [];
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
for (let page = 1; page <= options.config.maxOpenPages; page += 1) {
|
||||
const pagePulls = await fetchPullsPage({
|
||||
...options,
|
||||
state: "open",
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
pulls.push(...pagePulls);
|
||||
if (pagePulls.length < perPage) break;
|
||||
if (page === options.config.maxOpenPages) {
|
||||
warnings.push({
|
||||
repository: options.repository,
|
||||
type: "open-pr-page-limit",
|
||||
message: `Open PR pagination reached ${options.config.maxOpenPages} pages for ${options.repository}; dashboard counts may be capped. Increase GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES if needed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { pulls, warnings };
|
||||
}
|
||||
|
||||
async function fetchRecentlyClosedPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
now: Date;
|
||||
}): Promise<{
|
||||
pulls: GitHubPullRequestRecord[];
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const perPage = 100;
|
||||
const closedSinceMs =
|
||||
options.now.getTime() - options.config.recentlyClosedDays * 24 * 3_600_000;
|
||||
const pulls: GitHubPullRequestRecord[] = [];
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
for (let page = 1; page <= options.config.maxClosedPages; page += 1) {
|
||||
const pagePulls = await fetchPullsPage({
|
||||
...options,
|
||||
state: "closed",
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
if (pagePulls.length === 0) break;
|
||||
pulls.push(
|
||||
...pagePulls.filter((pull) => {
|
||||
const closedAt = pull.closedAt ?? pull.mergedAt;
|
||||
return closedAt ? new Date(closedAt).getTime() >= closedSinceMs : false;
|
||||
}),
|
||||
);
|
||||
|
||||
const oldestUpdatedMs = Math.min(
|
||||
...pagePulls.map((pull) => new Date(pull.updatedAt).getTime()),
|
||||
);
|
||||
if (pagePulls.length < perPage || oldestUpdatedMs < closedSinceMs) break;
|
||||
if (page === options.config.maxClosedPages) {
|
||||
warnings.push({
|
||||
repository: options.repository,
|
||||
type: "closed-pr-page-limit",
|
||||
message: `Recently closed PR pagination reached ${options.config.maxClosedPages} pages for ${options.repository}; recently closed counts may be capped. Increase GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES if needed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { pulls, warnings };
|
||||
}
|
||||
|
||||
async function fetchRecentActivityPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<GitHubPullRequestRecord[]> {
|
||||
return fetchPullsPage({
|
||||
...options,
|
||||
state: "all",
|
||||
page: 1,
|
||||
perPage: options.config.maxPullsPerRepo,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePullRequest(
|
||||
repository: string,
|
||||
pull: GitHubPullApiRecord,
|
||||
): GitHubPullRequestRecord | undefined {
|
||||
if (!Number.isFinite(pull.number)) return undefined;
|
||||
if (!pull.created_at || !pull.updated_at) return undefined;
|
||||
return {
|
||||
number: pull.number,
|
||||
title: pull.title ?? `Pull request #${pull.number}`,
|
||||
url:
|
||||
pull.html_url ?? `https://github.com/${repository}/pull/${pull.number}`,
|
||||
state: pull.state ?? "open",
|
||||
draft: pull.draft === true,
|
||||
author: pull.user?.login ?? "unknown",
|
||||
createdAt: pull.created_at,
|
||||
updatedAt: pull.updated_at,
|
||||
...(pull.closed_at ? { closedAt: pull.closed_at } : {}),
|
||||
...(pull.merged_at ? { mergedAt: pull.merged_at } : {}),
|
||||
requestedReviewers: (pull.requested_reviewers ?? [])
|
||||
.map((reviewer) => reviewer.login)
|
||||
.filter((login): login is string => Boolean(login)),
|
||||
requestedTeams: (pull.requested_teams ?? [])
|
||||
.map((team) => team.slug ?? team.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeReview(
|
||||
repository: string,
|
||||
prNumber: number,
|
||||
review: GitHubReviewApiRecord,
|
||||
): GitHubPullRequestReviewRecord | undefined {
|
||||
if (!review.submitted_at || !review.user?.login) return undefined;
|
||||
return {
|
||||
repository,
|
||||
prNumber,
|
||||
reviewer: review.user.login,
|
||||
state: review.state ?? "COMMENTED",
|
||||
submittedAt: review.submitted_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitHubPrDashboardData(options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchJson?: FetchJson;
|
||||
now?: Date;
|
||||
}): Promise<{
|
||||
config: GitHubPrDashboardConfig;
|
||||
pullsByRepo: Record<string, GitHubPullRequestRecord[]>;
|
||||
reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]>;
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const config = resolveGitHubPrDashboardConfig(options.env ?? process.env);
|
||||
const fetchJson = options.fetchJson ?? defaultFetchJson;
|
||||
const now = options.now ?? new Date();
|
||||
const pullsByRepo: Record<string, GitHubPullRequestRecord[]> = {};
|
||||
const reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]> = {};
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
|
||||
for (const repository of config.repositories) {
|
||||
// Open PR count must be exact, so fetch and paginate open PRs separately.
|
||||
// Review calls remain bounded to the recent activity sample to avoid one
|
||||
// extra API request per open PR on large repositories.
|
||||
const [openPullsResult, recentlyClosedPullsResult, recentActivityPulls] =
|
||||
await Promise.all([
|
||||
fetchAllOpenPulls({ repository, config, fetchJson }),
|
||||
fetchRecentlyClosedPulls({ repository, config, fetchJson, now }),
|
||||
fetchRecentActivityPulls({ repository, config, fetchJson }),
|
||||
]);
|
||||
warnings.push(...openPullsResult.warnings);
|
||||
warnings.push(...recentlyClosedPullsResult.warnings);
|
||||
const pulls = mergePullsByNumber(
|
||||
mergePullsByNumber(
|
||||
openPullsResult.pulls,
|
||||
recentlyClosedPullsResult.pulls,
|
||||
),
|
||||
recentActivityPulls,
|
||||
);
|
||||
pullsByRepo[repository] = pulls;
|
||||
|
||||
const reviews: GitHubPullRequestReviewRecord[] = [];
|
||||
for (const pull of recentActivityPulls) {
|
||||
const reviewsPayload = await fetchJson(
|
||||
`https://api.github.com/repos/${repository}/pulls/${pull.number}/reviews?per_page=100`,
|
||||
{ headers: headersFor(config) },
|
||||
);
|
||||
if (!Array.isArray(reviewsPayload)) continue;
|
||||
for (const review of reviewsPayload as GitHubReviewApiRecord[]) {
|
||||
const normalized = normalizeReview(repository, pull.number, review);
|
||||
if (normalized) reviews.push(normalized);
|
||||
}
|
||||
}
|
||||
reviewsByRepo[repository] = reviews;
|
||||
}
|
||||
|
||||
return { config, pullsByRepo, reviewsByRepo, warnings };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { AgentPlugin, BasicLogger } from "@cline/core";
|
||||
import { makeDashboardHandoffMessage } from "./format";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
|
||||
let setupLogger: BasicLogger | undefined;
|
||||
|
||||
interface PendingDashboardHandoff {
|
||||
text: string;
|
||||
snapshotHash: string;
|
||||
statePath: string;
|
||||
injected: boolean;
|
||||
}
|
||||
|
||||
const pendingDashboardHandoffs = new Map<string, PendingDashboardHandoff>();
|
||||
|
||||
export function resolveDashboardHandoffKey(snapshot: {
|
||||
runId?: string;
|
||||
conversationId?: string;
|
||||
agentId: string;
|
||||
}): string {
|
||||
return snapshot.runId ?? snapshot.conversationId ?? snapshot.agentId;
|
||||
}
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "github-pr-dashboard-gate",
|
||||
manifest: {
|
||||
capabilities: ["hooks"],
|
||||
},
|
||||
|
||||
setup(_api, ctx) {
|
||||
setupLogger = ctx.logger;
|
||||
},
|
||||
|
||||
hooks: {
|
||||
async beforeRun({ snapshot }) {
|
||||
const result = await runGitHubPrDashboardGate({ logger: setupLogger });
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
if (result.stop) {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
return { stop: true, reason: result.reason };
|
||||
}
|
||||
if (result.handoffText) {
|
||||
pendingDashboardHandoffs.set(key, {
|
||||
text: result.handoffText,
|
||||
snapshotHash: result.snapshotHash ?? "",
|
||||
statePath: result.statePath ?? "",
|
||||
injected: false,
|
||||
});
|
||||
} else {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
}
|
||||
return { reason: result.reason };
|
||||
},
|
||||
|
||||
beforeModel({ request, snapshot }) {
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
const pending = pendingDashboardHandoffs.get(key);
|
||||
if (!pending || pending.injected) return undefined;
|
||||
pending.injected = true;
|
||||
return {
|
||||
messages: [
|
||||
...request.messages,
|
||||
makeDashboardHandoffMessage(pending.text),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
afterRun({ result, snapshot }) {
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
const pending = pendingDashboardHandoffs.get(key);
|
||||
if (result.status !== "completed") {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
return;
|
||||
}
|
||||
if (!pending?.snapshotHash || !pending.statePath) return;
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: pending.snapshotHash,
|
||||
statePath: pending.statePath,
|
||||
});
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
export {
|
||||
formatDashboardHandoff,
|
||||
renderDashboardHtml,
|
||||
renderDashboardMarkdown,
|
||||
} from "./format";
|
||||
export type {
|
||||
ApplyGitHubPrDashboardSnapshotOptions,
|
||||
GitHubPrDashboardGateOptions,
|
||||
GitHubPrDashboardGateResult,
|
||||
} from "./gate";
|
||||
export {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
export {
|
||||
fetchGitHubPrDashboardData,
|
||||
normalizePullRequest,
|
||||
normalizeReview,
|
||||
} from "./github";
|
||||
export { buildDashboardSnapshot, hashDashboardSnapshot } from "./metrics";
|
||||
export type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
export type { GitHubPrDashboardState } from "./state";
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
GitHubPrDashboardSnapshot,
|
||||
GitHubPullRequestRecord,
|
||||
GitHubPullRequestReviewRecord,
|
||||
} from "./schema";
|
||||
|
||||
function timeMs(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function hoursBetween(start: string, end: Date): number {
|
||||
const startMs = timeMs(start) ?? end.getTime();
|
||||
return Math.max(0, (end.getTime() - startMs) / 3_600_000);
|
||||
}
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return round1(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function dateKey(value: string): string {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function increment(map: Map<string, number>, key: string, amount = 1): void {
|
||||
map.set(key, (map.get(key) ?? 0) + amount);
|
||||
}
|
||||
|
||||
function topCounts(
|
||||
map: Map<string, number>,
|
||||
): Array<{ login: string; count: number }> {
|
||||
return [...map.entries()]
|
||||
.map(([login, count]) => ({ login, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.login.localeCompare(right.login),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function countByAuthor(
|
||||
pulls: Array<{ author: string; createdAt: string }>,
|
||||
sinceMs: number,
|
||||
): Array<{ login: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const pull of pulls) {
|
||||
const createdMs = timeMs(pull.createdAt);
|
||||
if (createdMs !== undefined && createdMs >= sinceMs)
|
||||
increment(counts, pull.author);
|
||||
}
|
||||
return topCounts(counts);
|
||||
}
|
||||
|
||||
function countByReviewer(
|
||||
reviews: GitHubPullRequestReviewRecord[],
|
||||
sinceMs: number,
|
||||
): Array<{ login: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
const unique = new Set<string>();
|
||||
for (const review of reviews) {
|
||||
const submittedMs = timeMs(review.submittedAt);
|
||||
if (submittedMs === undefined || submittedMs < sinceMs) continue;
|
||||
const key = `${review.repository}:${review.prNumber}:${review.reviewer}`;
|
||||
if (unique.has(key)) continue;
|
||||
unique.add(key);
|
||||
increment(counts, review.reviewer);
|
||||
}
|
||||
return topCounts(counts);
|
||||
}
|
||||
|
||||
function emptyTrend(now: Date, trendDays: number) {
|
||||
return Array.from({ length: trendDays }, (_, index) => {
|
||||
const date = new Date(now);
|
||||
date.setUTCDate(date.getUTCDate() - (trendDays - index - 1));
|
||||
return {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
opened: 0,
|
||||
closed: 0,
|
||||
merged: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDashboardSnapshot(input: {
|
||||
generatedAt: Date;
|
||||
repositories: string[];
|
||||
pullsByRepo: Record<string, GitHubPullRequestRecord[]>;
|
||||
reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]>;
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
}): GitHubPrDashboardSnapshot {
|
||||
const now = input.generatedAt;
|
||||
const newSinceMs = now.getTime() - input.newPrHours * 3_600_000;
|
||||
const closedSinceMs =
|
||||
now.getTime() - input.recentlyClosedDays * 24 * 3_600_000;
|
||||
const weekSinceMs = now.getTime() - 7 * 24 * 3_600_000;
|
||||
const monthSinceMs = now.getTime() - 30 * 24 * 3_600_000;
|
||||
const trendSinceMs = now.getTime() - input.trendDays * 24 * 3_600_000;
|
||||
|
||||
const allPulls = input.repositories.flatMap((repository) =>
|
||||
(input.pullsByRepo[repository] ?? []).map((pull) => ({ repository, pull })),
|
||||
);
|
||||
const allReviews = input.repositories.flatMap(
|
||||
(repository) => input.reviewsByRepo[repository] ?? [],
|
||||
);
|
||||
const openPulls = allPulls.filter(({ pull }) => pull.state === "open");
|
||||
const newOpenPulls = openPulls.filter(
|
||||
({ pull }) => (timeMs(pull.createdAt) ?? 0) >= newSinceMs,
|
||||
);
|
||||
const recentlyClosedPulls = allPulls.filter(({ pull }) => {
|
||||
const closedMs = timeMs(pull.closedAt ?? pull.mergedAt);
|
||||
return closedMs !== undefined && closedMs >= closedSinceMs;
|
||||
});
|
||||
|
||||
const waitingForReview = openPulls
|
||||
.filter(
|
||||
({ pull }) =>
|
||||
!pull.draft &&
|
||||
(pull.requestedReviewers.length > 0 || pull.requestedTeams.length > 0),
|
||||
)
|
||||
.map(({ repository, pull }) => ({
|
||||
repository,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
url: pull.url,
|
||||
author: pull.author,
|
||||
waitingHours: round1(hoursBetween(pull.createdAt, now)),
|
||||
requestedReviewers: pull.requestedReviewers,
|
||||
requestedTeams: pull.requestedTeams,
|
||||
updatedAt: pull.updatedAt,
|
||||
}))
|
||||
.sort((left, right) => right.waitingHours - left.waitingHours)
|
||||
.slice(0, 25);
|
||||
|
||||
const trend = emptyTrend(now, input.trendDays);
|
||||
const trendByDate = new Map(trend.map((day) => [day.date, day]));
|
||||
for (const { pull } of allPulls) {
|
||||
const createdMs = timeMs(pull.createdAt);
|
||||
if (createdMs !== undefined && createdMs >= trendSinceMs) {
|
||||
const day = trendByDate.get(dateKey(pull.createdAt));
|
||||
if (day) day.opened += 1;
|
||||
}
|
||||
const closedAt = pull.closedAt ?? pull.mergedAt;
|
||||
const closedMs = timeMs(closedAt);
|
||||
if (closedAt && closedMs !== undefined && closedMs >= trendSinceMs) {
|
||||
const day = trendByDate.get(dateKey(closedAt));
|
||||
if (day) {
|
||||
day.closed += 1;
|
||||
if (pull.mergedAt) day.merged += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repositories = input.repositories.map((repository) => {
|
||||
const pulls = input.pullsByRepo[repository] ?? [];
|
||||
const repoOpen = pulls.filter((pull) => pull.state === "open");
|
||||
const repoWaiting = repoOpen.filter(
|
||||
(pull) =>
|
||||
!pull.draft &&
|
||||
(pull.requestedReviewers.length > 0 || pull.requestedTeams.length > 0),
|
||||
);
|
||||
return {
|
||||
repository,
|
||||
openCount: repoOpen.length,
|
||||
newOpenCount: repoOpen.filter(
|
||||
(pull) => (timeMs(pull.createdAt) ?? 0) >= newSinceMs,
|
||||
).length,
|
||||
recentlyClosedCount: pulls.filter((pull) => {
|
||||
const closedMs = timeMs(pull.closedAt ?? pull.mergedAt);
|
||||
return closedMs !== undefined && closedMs >= closedSinceMs;
|
||||
}).length,
|
||||
avgOpenAgeHours: average(
|
||||
repoOpen.map((pull) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
avgWaitingForReviewHours: average(
|
||||
repoWaiting.map((pull) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
repositories: input.repositories,
|
||||
window: {
|
||||
newPrHours: input.newPrHours,
|
||||
recentlyClosedDays: input.recentlyClosedDays,
|
||||
trendDays: input.trendDays,
|
||||
},
|
||||
summary: {
|
||||
openCount: openPulls.length,
|
||||
newOpenCount: newOpenPulls.length,
|
||||
recentlyClosedCount: recentlyClosedPulls.length,
|
||||
avgOpenAgeHours: average(
|
||||
openPulls.map(({ pull }) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
avgWaitingForReviewHours: average(
|
||||
waitingForReview.map((pull) => pull.waitingHours),
|
||||
),
|
||||
},
|
||||
waitingForReview,
|
||||
volumeTrend: trend,
|
||||
leadingAuthors: {
|
||||
week: countByAuthor(
|
||||
allPulls.map(({ pull }) => pull),
|
||||
weekSinceMs,
|
||||
),
|
||||
month: countByAuthor(
|
||||
allPulls.map(({ pull }) => pull),
|
||||
monthSinceMs,
|
||||
),
|
||||
},
|
||||
leadingReviewers: {
|
||||
week: countByReviewer(allReviews, weekSinceMs),
|
||||
month: countByReviewer(allReviews, monthSinceMs),
|
||||
},
|
||||
repositoryBreakdown: repositories,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashDashboardSnapshot(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
): string {
|
||||
const stableSnapshot = {
|
||||
...snapshot,
|
||||
generatedAt: undefined,
|
||||
summary: {
|
||||
...snapshot.summary,
|
||||
avgOpenAgeHours: 0,
|
||||
avgWaitingForReviewHours: 0,
|
||||
},
|
||||
waitingForReview: snapshot.waitingForReview.map((pull) => ({
|
||||
...pull,
|
||||
waitingHours: 0,
|
||||
})),
|
||||
repositoryBreakdown: snapshot.repositoryBreakdown.map((repository) => ({
|
||||
...repository,
|
||||
avgOpenAgeHours: 0,
|
||||
avgWaitingForReviewHours: 0,
|
||||
})),
|
||||
};
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(stableSnapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { renderDashboardHtml, renderDashboardMarkdown } from "./format";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
|
||||
function readFlag(name: string): string | undefined {
|
||||
const args = process.argv.slice(2);
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) return inline.slice(prefix.length).trim() || undefined;
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
const value = args[index + 1];
|
||||
return value && !value.startsWith("--")
|
||||
? value.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function applyCliOverrides(): void {
|
||||
const repositories = readFlag("--repo") ?? readFlag("--repos");
|
||||
if (repositories) process.env.GITHUB_REPOSITORIES = repositories;
|
||||
|
||||
const markdownPath = readFlag("--output");
|
||||
if (markdownPath) process.env.GITHUB_PR_DASHBOARD_PATH = markdownPath;
|
||||
|
||||
const htmlPath = readFlag("--html-output");
|
||||
if (htmlPath) process.env.GITHUB_PR_DASHBOARD_HTML_PATH = htmlPath;
|
||||
|
||||
const statePath = readFlag("--state");
|
||||
if (statePath) process.env.GITHUB_PR_DASHBOARD_STATE_PATH = statePath;
|
||||
|
||||
const maxRecent = readFlag("--max-recent");
|
||||
if (maxRecent) process.env.GITHUB_PR_DASHBOARD_MAX_PRS = maxRecent;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`GitHub PR Dashboard preview
|
||||
|
||||
Fetch GitHub PR metrics and write a dashboard Markdown + HTML file without
|
||||
starting an agent/model. This uses the same deterministic gate as the plugin.
|
||||
|
||||
Minimal usage:
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo owner/repo
|
||||
|
||||
Recommended when GitHub rate-limits unauthenticated requests:
|
||||
GITHUB_TOKEN=$(gh auth token) bun -F cline-github-pr-dashboard-plugin run-once -- --repo owner/repo
|
||||
|
||||
Advanced optional env:
|
||||
GITHUB_PR_DASHBOARD_PATH=github-pr-dashboard.md
|
||||
GITHUB_PR_DASHBOARD_HTML_PATH=github-pr-dashboard.html
|
||||
GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS=25 # recent activity sample size, not open PR cap
|
||||
GITHUB_PR_DASHBOARD_NEW_HOURS=24
|
||||
GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS=7
|
||||
GITHUB_PR_DASHBOARD_TREND_DAYS=14
|
||||
|
||||
Usage:
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline
|
||||
|
||||
Flags:
|
||||
--repo owner/repo[,owner/repo] Repositories to inspect. Also accepts --repos.
|
||||
--output path Markdown output path.
|
||||
--html-output path HTML output path.
|
||||
--state path State/cache file path.
|
||||
--max-recent count Recent activity sample size for review details.
|
||||
--open Open the generated HTML dashboard on macOS.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function resolveOutputPath(path: string): string {
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
function defaultHtmlPath(markdownPath: string): string {
|
||||
const ext = extname(markdownPath);
|
||||
return ext
|
||||
? `${markdownPath.slice(0, -ext.length)}.html`
|
||||
: `${markdownPath}.html`;
|
||||
}
|
||||
|
||||
function writeTextFile(path: string, text: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
}
|
||||
|
||||
async function openIfRequested(
|
||||
path: string,
|
||||
requested: boolean,
|
||||
): Promise<void> {
|
||||
if (!requested) return;
|
||||
if (process.platform !== "darwin") {
|
||||
console.warn(
|
||||
"--open is only implemented for macOS; open the HTML path manually.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const child = Bun.spawn(["open", path], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
await child.exited;
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (args.has("--help") || args.has("-h")) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
applyCliOverrides();
|
||||
|
||||
if (!process.env.GITHUB_REPOSITORIES?.trim()) {
|
||||
console.error(
|
||||
"Missing repository. Pass --repo owner/repo or set GITHUB_REPOSITORIES=owner/repo[,owner/repo].",
|
||||
);
|
||||
console.error(
|
||||
'Example: GITHUB_TOKEN="$(gh auth token)" bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await runGitHubPrDashboardGate();
|
||||
|
||||
if (!result.snapshot || !result.dashboardPath) {
|
||||
throw new Error(
|
||||
"GitHub PR dashboard gate did not return a dashboard snapshot",
|
||||
);
|
||||
}
|
||||
|
||||
const markdownPath = resolveOutputPath(result.dashboardPath);
|
||||
const htmlPath = resolveOutputPath(
|
||||
process.env.GITHUB_PR_DASHBOARD_HTML_PATH?.trim() ||
|
||||
defaultHtmlPath(result.dashboardPath),
|
||||
);
|
||||
|
||||
writeTextFile(markdownPath, renderDashboardMarkdown(result.snapshot));
|
||||
writeTextFile(
|
||||
htmlPath,
|
||||
renderDashboardHtml(result.snapshot, {
|
||||
checkpointStatus: result.stop ? "unchanged" : "changed",
|
||||
checkpointReason: result.reason,
|
||||
changeSummary: result.changeSummary,
|
||||
snapshotHash: result.snapshotHash,
|
||||
}),
|
||||
);
|
||||
if (result.snapshotHash) {
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: result.snapshotHash,
|
||||
statePath: result.statePath,
|
||||
});
|
||||
}
|
||||
await openIfRequested(htmlPath, args.has("--open"));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
changed: result.stop !== true,
|
||||
stop: result.stop ?? false,
|
||||
reason: result.reason,
|
||||
snapshotHash: result.snapshotHash,
|
||||
markdownPath,
|
||||
htmlPath,
|
||||
summary: result.snapshot.summary,
|
||||
changes: result.changeSummary,
|
||||
warnings: result.warnings?.map((warning) => warning.message),
|
||||
next: args.has("--open")
|
||||
? undefined
|
||||
: `Open ${htmlPath} in a browser to view the dashboard.`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface GitHubUserRef {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestRecord {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: "open" | "closed" | string;
|
||||
draft: boolean;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string;
|
||||
mergedAt?: string;
|
||||
requestedReviewers: string[];
|
||||
requestedTeams: string[];
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestReviewRecord {
|
||||
repository: string;
|
||||
prNumber: number;
|
||||
reviewer: string;
|
||||
state: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardSnapshot {
|
||||
generatedAt: string;
|
||||
repositories: string[];
|
||||
window: {
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
};
|
||||
summary: {
|
||||
openCount: number;
|
||||
newOpenCount: number;
|
||||
recentlyClosedCount: number;
|
||||
avgOpenAgeHours: number;
|
||||
avgWaitingForReviewHours: number;
|
||||
};
|
||||
waitingForReview: Array<{
|
||||
repository: string;
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
author: string;
|
||||
waitingHours: number;
|
||||
requestedReviewers: string[];
|
||||
requestedTeams: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
volumeTrend: Array<{
|
||||
date: string;
|
||||
opened: number;
|
||||
closed: number;
|
||||
merged: number;
|
||||
}>;
|
||||
leadingAuthors: {
|
||||
week: Array<{ login: string; count: number }>;
|
||||
month: Array<{ login: string; count: number }>;
|
||||
};
|
||||
leadingReviewers: {
|
||||
week: Array<{ login: string; count: number }>;
|
||||
month: Array<{ login: string; count: number }>;
|
||||
};
|
||||
repositoryBreakdown: Array<{
|
||||
repository: string;
|
||||
openCount: number;
|
||||
newOpenCount: number;
|
||||
recentlyClosedCount: number;
|
||||
avgOpenAgeHours: number;
|
||||
avgWaitingForReviewHours: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardRun {
|
||||
runId: string;
|
||||
snapshotHash: string;
|
||||
dashboardPath: string;
|
||||
snapshot: GitHubPrDashboardSnapshot;
|
||||
changeSummary: string[];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { GitHubPrDashboardSnapshot } from "./schema";
|
||||
|
||||
export interface GitHubPrDashboardState {
|
||||
version: 1;
|
||||
lastSnapshotHash?: string;
|
||||
lastGeneratedAt?: string;
|
||||
lastSnapshot?: GitHubPrDashboardSnapshot;
|
||||
pendingSnapshotHash?: string;
|
||||
pendingGeneratedAt?: string;
|
||||
pendingSnapshot?: GitHubPrDashboardSnapshot;
|
||||
}
|
||||
|
||||
export const EMPTY_GITHUB_PR_DASHBOARD_STATE: GitHubPrDashboardState = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
function dataDirFromEnv(env: NodeJS.ProcessEnv): string {
|
||||
return env.CLINE_DATA_DIR?.trim() || join(homedir(), ".cline", "data");
|
||||
}
|
||||
|
||||
export function resolveStatePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.GITHUB_PR_DASHBOARD_STATE_PATH?.trim() ||
|
||||
join(dataDirFromEnv(env), "plugins", "github-pr-dashboard", "state.json")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): GitHubPrDashboardState {
|
||||
if (!value || typeof value !== "object") return { version: 1 };
|
||||
const input = value as Partial<GitHubPrDashboardState>;
|
||||
return {
|
||||
version: 1,
|
||||
...(typeof input.lastSnapshotHash === "string"
|
||||
? { lastSnapshotHash: input.lastSnapshotHash }
|
||||
: {}),
|
||||
...(typeof input.lastGeneratedAt === "string"
|
||||
? { lastGeneratedAt: input.lastGeneratedAt }
|
||||
: {}),
|
||||
...(input.lastSnapshot && typeof input.lastSnapshot === "object"
|
||||
? { lastSnapshot: input.lastSnapshot }
|
||||
: {}),
|
||||
...(typeof input.pendingSnapshotHash === "string"
|
||||
? { pendingSnapshotHash: input.pendingSnapshotHash }
|
||||
: {}),
|
||||
...(typeof input.pendingGeneratedAt === "string"
|
||||
? { pendingGeneratedAt: input.pendingGeneratedAt }
|
||||
: {}),
|
||||
...(input.pendingSnapshot && typeof input.pendingSnapshot === "object"
|
||||
? { pendingSnapshot: input.pendingSnapshot }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function markSnapshotApplied(
|
||||
state: GitHubPrDashboardState,
|
||||
snapshotHash: string,
|
||||
): GitHubPrDashboardState {
|
||||
if (state.pendingSnapshotHash !== snapshotHash || !state.pendingSnapshot) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
lastSnapshotHash: state.pendingSnapshotHash,
|
||||
lastGeneratedAt: state.pendingGeneratedAt,
|
||||
lastSnapshot: state.pendingSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function readState(path = resolveStatePath()): GitHubPrDashboardState {
|
||||
if (!existsSync(path)) return { version: 1 };
|
||||
try {
|
||||
return normalizeState(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch {
|
||||
return { version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeState(
|
||||
state: GitHubPrDashboardState,
|
||||
path = resolveStatePath(),
|
||||
): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(normalizeState(state), null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.55",
|
||||
"version": "0.0.54",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.55",
|
||||
"version": "0.0.54",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -165,7 +165,9 @@ describe("ClineAccountService", () => {
|
||||
userId: "user-1",
|
||||
};
|
||||
const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
expect(String(input)).toBe("https://api.cline.bot/api/v1/users/me/plan");
|
||||
expect(String(input)).toBe(
|
||||
"https://api.cline.bot/api/v1/users/me/plan",
|
||||
);
|
||||
expect(init?.headers).toMatchObject({
|
||||
Authorization: "Bearer workos:token-123",
|
||||
});
|
||||
|
||||
@@ -273,7 +273,6 @@ function trimCandidatesToBudget(
|
||||
candidates: BasicCompactionCandidate[],
|
||||
targetTokens: number,
|
||||
totalTokens: number,
|
||||
triggerTokens: number,
|
||||
estimateMessageTokens: EstimateMessageTokens,
|
||||
): number {
|
||||
if (totalTokens <= targetTokens) {
|
||||
@@ -312,10 +311,6 @@ function trimCandidatesToBudget(
|
||||
(candidate) => candidate.isFirstUser,
|
||||
);
|
||||
if (firstUserIndex >= 0) {
|
||||
const firstUser = candidates[firstUserIndex];
|
||||
if (firstUser.estimatedTokens <= triggerTokens) {
|
||||
return totalTokens;
|
||||
}
|
||||
while (totalTokens > targetTokens) {
|
||||
const candidate = candidates[firstUserIndex];
|
||||
const desiredTokens = Math.max(
|
||||
@@ -436,7 +431,6 @@ export function runBasicCompaction(options: {
|
||||
candidates,
|
||||
targetTokens,
|
||||
totalTokens,
|
||||
options.context.triggerTokens,
|
||||
options.estimateMessageTokens,
|
||||
);
|
||||
|
||||
|
||||
@@ -36,14 +36,13 @@ function totalJsonTokens(messages: LlmsProviders.Message[]): number {
|
||||
}
|
||||
|
||||
describe("createTokenEstimator", () => {
|
||||
it("does not treat cumulative request metrics as per-message token counts", () => {
|
||||
it("does not treat assistant request metrics as per-message token counts", () => {
|
||||
const estimateMessageTokens = createTokenEstimator();
|
||||
const message: MessageWithMetadata = {
|
||||
role: "assistant",
|
||||
content: "short",
|
||||
metrics: {
|
||||
inputTokens: 100,
|
||||
cacheReadTokens: 80,
|
||||
inputTokens: 12,
|
||||
outputTokens: 7,
|
||||
},
|
||||
};
|
||||
@@ -411,91 +410,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(compacted).toBe(messages);
|
||||
});
|
||||
|
||||
it("does not truncate a shallow first task prompt below the trigger for high-output models", async () => {
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
maxInputTokens: 1_000,
|
||||
thresholdRatio: 0.9,
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
const task =
|
||||
'<user_input mode="act">Create /app/filter.py that removes JavaScript from HTML files. ' +
|
||||
"Keep this task prompt intact. ".repeat(25) +
|
||||
"</user_input>";
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: task },
|
||||
{ role: "assistant", content: "old assistant context ".repeat(500) },
|
||||
{ role: "user", content: "Continue" },
|
||||
];
|
||||
|
||||
const result = await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "minimax/minimax-m3",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "minimax/minimax-m3",
|
||||
maxInputTokens: 1_000,
|
||||
maxTokens: 950,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.messages?.[0]?.content).toBe(task);
|
||||
expect(JSON.stringify(result?.messages)).toContain("Create /app/filter.py");
|
||||
expect(JSON.stringify(result?.messages)).not.toContain("<user_input\n...");
|
||||
});
|
||||
|
||||
it("can truncate an oversized first task prompt when it exceeds the trigger", () => {
|
||||
const oversizedPrompt = "<user_input>".repeat(500);
|
||||
const messages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: oversizedPrompt },
|
||||
{ role: "assistant", content: "old assistant context ".repeat(500) },
|
||||
{ role: "user", content: "current turn" },
|
||||
];
|
||||
|
||||
const compacted = runBasicCompaction({
|
||||
context: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
messages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "openrouter",
|
||||
info: { id: "mock-model", maxInputTokens: 1_000 },
|
||||
},
|
||||
maxInputTokens: 1_000,
|
||||
triggerTokens: 900,
|
||||
targetTokens: 100,
|
||||
thresholdRatio: 0.9,
|
||||
utilizationRatio: 2,
|
||||
},
|
||||
estimateMessageTokens: estimateJsonTokens,
|
||||
});
|
||||
|
||||
expect(compacted?.messages[0]?.content).not.toBe(oversizedPrompt);
|
||||
expect(String(compacted?.messages[0]?.content)).toContain("\n...");
|
||||
});
|
||||
|
||||
it("does not add unsupported max output tokens to Codex OAuth summarizer requests", () => {
|
||||
const codexConfig = resolveSummarizerConfig({
|
||||
activeProviderConfig: {
|
||||
@@ -1244,7 +1158,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("targets basic compaction at half the input budget for long conversations", async () => {
|
||||
it("targets basic compaction below the model output-reserved input budget", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
{ role: "user" as const, content: "Compacted by target budget" },
|
||||
@@ -1261,17 +1175,10 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "assistant", content: "answer 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "answer 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
{ role: "assistant", content: "answer 3" },
|
||||
{ role: "user", content: "turn 4" },
|
||||
{ role: "assistant", content: "answer 4" },
|
||||
{ role: "user", content: "turn 5" },
|
||||
{ role: "assistant", content: "answer 5" },
|
||||
{ role: "user", content: "large prompt ".repeat(70_000) },
|
||||
{
|
||||
role: "user",
|
||||
content: "large prompt ".repeat(70_000),
|
||||
},
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
@@ -1298,69 +1205,7 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(244_800);
|
||||
expect(context?.targetTokens).toBe(136_000);
|
||||
});
|
||||
|
||||
it("keeps the long-conversation target below low custom trigger thresholds", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
{ role: "user" as const, content: "Compacted by low threshold" },
|
||||
],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "anthropic",
|
||||
modelId: "mock-model",
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "mock-model",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: {
|
||||
enabled: true,
|
||||
strategy: "basic",
|
||||
thresholdRatio: 0.4,
|
||||
compact,
|
||||
},
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{ role: "user", content: "turn 1" },
|
||||
{ role: "assistant", content: "answer 1" },
|
||||
{ role: "user", content: "turn 2" },
|
||||
{ role: "assistant", content: "answer 2" },
|
||||
{ role: "user", content: "turn 3" },
|
||||
{ role: "assistant", content: "answer 3" },
|
||||
{ role: "user", content: "turn 4" },
|
||||
{ role: "assistant", content: "answer 4" },
|
||||
{ role: "user", content: "turn 5" },
|
||||
{ role: "assistant", content: "answer 5" },
|
||||
{ role: "user", content: "large prompt ".repeat(20) },
|
||||
];
|
||||
|
||||
await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "anthropic",
|
||||
info: {
|
||||
id: "mock-model",
|
||||
maxInputTokens: 100,
|
||||
maxTokens: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
const context = compact.mock.calls[0]?.[0];
|
||||
expect(context?.triggerTokens).toBe(40);
|
||||
expect(context?.targetTokens).toBe(39);
|
||||
expect(context?.targetTokens).toBe(100_800);
|
||||
});
|
||||
|
||||
it("derives input budget by reserving model max output tokens from context window", async () => {
|
||||
@@ -1513,52 +1358,6 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not collapse context-only input budget when output is nearly the full context", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [{ role: "user" as const, content: "Compacted by fallback" }],
|
||||
}));
|
||||
const prepareTurn = createContextCompactionPrepareTurn({
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
providerConfig: {
|
||||
providerId: "openrouter",
|
||||
modelId: "minimax/minimax-m3",
|
||||
} as LlmsProviders.ProviderConfig,
|
||||
compaction: { enabled: true, strategy: "basic", compact },
|
||||
logger: undefined,
|
||||
});
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "regex prompt ".repeat(2_000),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prepareTurn?.({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [],
|
||||
messages,
|
||||
apiMessages: messages,
|
||||
model: {
|
||||
id: "minimax/minimax-m3",
|
||||
provider: "openrouter",
|
||||
info: {
|
||||
id: "minimax/minimax-m3",
|
||||
contextWindow: 524_288,
|
||||
maxTokens: 512_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("triggers compaction from provider-sized tool result payloads", async () => {
|
||||
const compact = vi.fn((_context: CoreCompactionContext) => ({
|
||||
messages: [
|
||||
|
||||
@@ -69,9 +69,6 @@ export interface ContextCompactionPrepareTurnOptions {
|
||||
manualTargetRatio?: number;
|
||||
}
|
||||
|
||||
const MIN_CONTEXT_DERIVED_INPUT_RATIO = 0.5;
|
||||
const LONG_CONVERSATION_TARGET_RATIO = 0.5;
|
||||
|
||||
function safeJsonSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
@@ -99,15 +96,11 @@ function resolveMaxInputTokens(input: {
|
||||
}
|
||||
if (isPositiveFiniteNumber(input.contextWindow)) {
|
||||
candidates.push(input.contextWindow);
|
||||
const derivedInputTokens = isPositiveFiniteNumber(input.modelMaxTokens)
|
||||
? input.contextWindow - input.modelMaxTokens
|
||||
: undefined;
|
||||
if (
|
||||
isPositiveFiniteNumber(derivedInputTokens) &&
|
||||
derivedInputTokens >=
|
||||
input.contextWindow * MIN_CONTEXT_DERIVED_INPUT_RATIO
|
||||
isPositiveFiniteNumber(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens < input.contextWindow
|
||||
) {
|
||||
candidates.push(derivedInputTokens);
|
||||
candidates.push(input.contextWindow - input.modelMaxTokens);
|
||||
}
|
||||
}
|
||||
return candidates.length > 0
|
||||
@@ -252,38 +245,22 @@ function resolveBasicTargetTokens(input: {
|
||||
maxInputTokens: number;
|
||||
modelMaxTokens?: number;
|
||||
triggerTokens: number;
|
||||
messagePairCount: number;
|
||||
}): number {
|
||||
const targetTokens =
|
||||
input.messagePairCount >= 5 &&
|
||||
const targetBaseTokens =
|
||||
typeof input.modelMaxTokens === "number" &&
|
||||
Number.isFinite(input.modelMaxTokens) &&
|
||||
input.modelMaxTokens < input.maxInputTokens
|
||||
? Math.floor(input.maxInputTokens * LONG_CONVERSATION_TARGET_RATIO)
|
||||
: Math.floor(input.triggerTokens * DEFAULT_TARGET_RATIO);
|
||||
const triggerCeiling = Math.max(1, input.triggerTokens - 1);
|
||||
? input.maxInputTokens - input.modelMaxTokens
|
||||
: input.triggerTokens;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(targetTokens, input.maxInputTokens, triggerCeiling),
|
||||
Math.min(
|
||||
Math.floor(targetBaseTokens * DEFAULT_TARGET_RATIO),
|
||||
input.maxInputTokens,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function countUserAssistantPairs(
|
||||
messages: CoreCompactionContext["messages"],
|
||||
): number {
|
||||
let pairs = 0;
|
||||
let hasPendingUser = false;
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
hasPendingUser = true;
|
||||
} else if (message.role === "assistant" && hasPendingUser) {
|
||||
pairs += 1;
|
||||
hasPendingUser = false;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `prepareTurn` callback used by the agent runtime to compact the
|
||||
* transcript before each model request.
|
||||
@@ -374,38 +351,37 @@ export function createContextCompactionPrepareTurn(
|
||||
if (mode === "auto" && !triggerState.shouldCompact) {
|
||||
return undefined;
|
||||
}
|
||||
const targetState =
|
||||
mode === "manual"
|
||||
? resolveManualTargetState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
const targetState =
|
||||
mode === "manual"
|
||||
? resolveManualTargetState({
|
||||
inputTokens,
|
||||
maxInputTokens,
|
||||
autoTriggerTokens: triggerState.triggerTokens,
|
||||
manualTargetRatio: options.manualTargetRatio,
|
||||
})
|
||||
: triggerState;
|
||||
const targetTokens =
|
||||
mode === "auto"
|
||||
? resolveBasicTargetTokens({
|
||||
maxInputTokens,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
messagePairCount: countUserAssistantPairs(context.messages),
|
||||
})
|
||||
: undefined;
|
||||
: triggerState;
|
||||
const targetTokens =
|
||||
mode === "auto"
|
||||
? resolveBasicTargetTokens({
|
||||
maxInputTokens,
|
||||
modelMaxTokens: context.model.info?.maxTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const compactionContext = {
|
||||
agentId: context.agentId,
|
||||
conversationId: context.conversationId,
|
||||
const compactionContext = {
|
||||
agentId: context.agentId,
|
||||
conversationId: context.conversationId,
|
||||
parentAgentId: context.parentAgentId,
|
||||
iteration: context.iteration,
|
||||
messages: context.messages,
|
||||
model: context.model,
|
||||
maxInputTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
targetTokens,
|
||||
thresholdRatio: targetState.thresholdRatio,
|
||||
utilizationRatio: maxInputTokens > 0 ? inputTokens / maxInputTokens : 0,
|
||||
};
|
||||
model: context.model,
|
||||
maxInputTokens,
|
||||
triggerTokens: targetState.triggerTokens,
|
||||
targetTokens,
|
||||
thresholdRatio: targetState.thresholdRatio,
|
||||
utilizationRatio: maxInputTokens > 0 ? inputTokens / maxInputTokens : 0,
|
||||
};
|
||||
|
||||
const statusReason =
|
||||
mode === "manual" ? "manual_compaction" : "auto_compaction";
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -422,18 +413,8 @@ describe("mcp config loader", () => {
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://linear.example.com",
|
||||
},
|
||||
},
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://github.example.com",
|
||||
},
|
||||
},
|
||||
linear: { transport: { type: "streamableHttp", url: "https://linear.example.com" } },
|
||||
github: { transport: { type: "streamableHttp", url: "https://github.example.com" } },
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -442,28 +423,16 @@ describe("mcp config loader", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
updateMcpServerOAuthState(
|
||||
"linear",
|
||||
() => ({ tokens: { access_token: "linear-token" } }),
|
||||
{
|
||||
filePath,
|
||||
},
|
||||
);
|
||||
updateMcpServerOAuthState(
|
||||
"github",
|
||||
() => ({ tokens: { access_token: "github-token" } }),
|
||||
{
|
||||
filePath,
|
||||
},
|
||||
);
|
||||
updateMcpServerOAuthState("linear", () => ({ tokens: { access_token: "linear-token" } }), {
|
||||
filePath,
|
||||
});
|
||||
updateMcpServerOAuthState("github", () => ({ tokens: { access_token: "github-token" } }), {
|
||||
filePath,
|
||||
});
|
||||
|
||||
const written = JSON.parse(await readFile(filePath, "utf8"));
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe(
|
||||
"linear-token",
|
||||
);
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe(
|
||||
"github-token",
|
||||
);
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe("linear-token");
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe("github-token");
|
||||
// Lockfile is released after each critical section.
|
||||
expect(existsSync(`${filePath}.lock`)).toBe(false);
|
||||
});
|
||||
@@ -472,11 +441,7 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
|
||||
// Simulate a crashed writer that left a lock directory behind, backdated well
|
||||
// past the 10s stale threshold.
|
||||
@@ -502,11 +467,7 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
|
||||
const lockPath = `${filePath}.lock`;
|
||||
updateMcpSettingsFileSync(filePath, () => {
|
||||
@@ -518,11 +479,7 @@ describe("mcp config loader", () => {
|
||||
rmdirSync(lockPath);
|
||||
const replacement = `${lockPath}.replacement`;
|
||||
mkdirSync(replacement);
|
||||
writeFileSync(
|
||||
join(replacement, "owner.replacement"),
|
||||
"replacement-owner",
|
||||
{ flag: "wx" },
|
||||
);
|
||||
writeFileSync(join(replacement, "owner.replacement"), "replacement-owner", { flag: "wx" });
|
||||
renameSync(replacement, lockPath);
|
||||
});
|
||||
|
||||
@@ -533,11 +490,7 @@ describe("mcp config loader", () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
|
||||
let count = 0;
|
||||
expect(() =>
|
||||
@@ -554,24 +507,16 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
tempRoots.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function makeSettingsFile(): Promise<string> {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-mcp-config-loader-async-"),
|
||||
);
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-async-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ mcpServers: {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(filePath, JSON.stringify({ mcpServers: {} }, null, 2), "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -581,9 +526,7 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
|
||||
const result = await updateMcpSettingsFile(filePath, (settings) => {
|
||||
mutatorRan = true;
|
||||
settings.mcpServers = {
|
||||
alpha: { transport: { type: "stdio", command: "node" } },
|
||||
};
|
||||
settings.mcpServers = { alpha: { transport: { type: "stdio", command: "node" } } };
|
||||
return "ok";
|
||||
});
|
||||
|
||||
@@ -611,10 +554,7 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
const linear = updateMcpSettingsFile(
|
||||
filePath,
|
||||
(settings) => {
|
||||
const servers = settings.mcpServers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const servers = settings.mcpServers as Record<string, Record<string, unknown>>;
|
||||
servers.linear.oauth = { tokens: { access_token: "linear-token" } };
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
@@ -622,10 +562,7 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
const github = updateMcpSettingsFile(
|
||||
filePath,
|
||||
(settings) => {
|
||||
const servers = settings.mcpServers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const servers = settings.mcpServers as Record<string, Record<string, unknown>>;
|
||||
servers.github.oauth = { tokens: { access_token: "github-token" } };
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
@@ -638,18 +575,8 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://linear.example.com",
|
||||
},
|
||||
},
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://github.example.com",
|
||||
},
|
||||
},
|
||||
linear: { transport: { type: "streamableHttp", url: "https://linear.example.com" } },
|
||||
github: { transport: { type: "streamableHttp", url: "https://github.example.com" } },
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -663,12 +590,8 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
await Promise.all([linear, github]);
|
||||
|
||||
const written = JSON.parse(await readFile(filePath, "utf8"));
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe(
|
||||
"linear-token",
|
||||
);
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe(
|
||||
"github-token",
|
||||
);
|
||||
expect(written.mcpServers.linear.oauth?.tokens?.access_token).toBe("linear-token");
|
||||
expect(written.mcpServers.github.oauth?.tokens?.access_token).toBe("github-token");
|
||||
// Lock released after each critical section.
|
||||
expect(existsSync(lockDir)).toBe(false);
|
||||
// The whole point of the async path: it must never freeze the loop.
|
||||
@@ -679,9 +602,7 @@ describe("updateMcpSettingsFile (async acquisition)", () => {
|
||||
});
|
||||
|
||||
it("creates a missing settings file inside the lock", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-mcp-config-loader-async-"),
|
||||
);
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "core-mcp-config-loader-async-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const filePath = join(tempRoot, "cline_mcp_settings.json");
|
||||
|
||||
|
||||
@@ -309,18 +309,12 @@ interface AcquiredSettingsLock {
|
||||
* operations and avoids inode- or handle-based deletion, so it works with
|
||||
* Node's portable fs APIs on Windows and POSIX.
|
||||
*/
|
||||
function tryAcquireSettingsLock(
|
||||
lockDir: string,
|
||||
token: string,
|
||||
): AcquiredSettingsLock | undefined {
|
||||
function tryAcquireSettingsLock(lockDir: string, token: string): AcquiredSettingsLock | undefined {
|
||||
mkdirSync(dirname(lockDir), { recursive: true });
|
||||
const stagingDir = `${lockDir}.tmp.${token}`;
|
||||
rmSync(stagingDir, { recursive: true, force: true });
|
||||
mkdirSync(stagingDir, { recursive: true });
|
||||
writeFileSync(join(stagingDir, `owner.${token}`), token, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
});
|
||||
writeFileSync(join(stagingDir, `owner.${token}`), token, { encoding: "utf8", flag: "wx" });
|
||||
try {
|
||||
renameSync(stagingDir, lockDir);
|
||||
return { lockDir, ownerFile: join(lockDir, `owner.${token}`) };
|
||||
@@ -333,10 +327,7 @@ function tryAcquireSettingsLock(
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimStaleLock(
|
||||
lockDir: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): void {
|
||||
function reclaimStaleLock(lockDir: string, options: McpSettingsLockOptions): void {
|
||||
let ageMs: number;
|
||||
try {
|
||||
ageMs = Date.now() - statSync(lockDir).mtimeMs;
|
||||
@@ -349,12 +340,9 @@ function reclaimStaleLock(
|
||||
if (ageMs < SETTINGS_LOCK_STALE_MS) {
|
||||
return;
|
||||
}
|
||||
options.logger?.log(
|
||||
`[mcp-settings] Stale lock directory at ${lockDir} (age ${ageMs}ms); reclaiming.`,
|
||||
{
|
||||
severity: "warn",
|
||||
},
|
||||
);
|
||||
options.logger?.log(`[mcp-settings] Stale lock directory at ${lockDir} (age ${ageMs}ms); reclaiming.`, {
|
||||
severity: "warn",
|
||||
});
|
||||
const staleDir = `${lockDir}.stale.${makeLockToken()}`;
|
||||
try {
|
||||
renameSync(lockDir, staleDir);
|
||||
@@ -395,10 +383,7 @@ function beginAcquire(filePath: string): { lockDir: string; token: string } {
|
||||
return { lockDir, token: makeLockToken() };
|
||||
}
|
||||
|
||||
function acquireSettingsLockSync(
|
||||
filePath: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): AcquiredSettingsLock {
|
||||
function acquireSettingsLockSync(filePath: string, options: McpSettingsLockOptions): AcquiredSettingsLock {
|
||||
const { lockDir, token } = beginAcquire(filePath);
|
||||
const timeoutMs = options.timeoutMs ?? SETTINGS_LOCK_STALE_MS;
|
||||
const startedAt = Date.now();
|
||||
@@ -423,10 +408,7 @@ function acquireSettingsLockSync(
|
||||
* attempts. Reclaims a stale lock left by a crashed holder and throws
|
||||
* McpSettingsLockTimeoutError once `timeoutMs` elapses.
|
||||
*/
|
||||
async function acquireSettingsLockAsync(
|
||||
filePath: string,
|
||||
options: McpSettingsLockOptions,
|
||||
): Promise<AcquiredSettingsLock> {
|
||||
async function acquireSettingsLockAsync(filePath: string, options: McpSettingsLockOptions): Promise<AcquiredSettingsLock> {
|
||||
const { lockDir, token } = beginAcquire(filePath);
|
||||
const timeoutMs = options.timeoutMs ?? SETTINGS_LOCK_STALE_MS;
|
||||
const startedAt = Date.now();
|
||||
@@ -451,11 +433,7 @@ async function acquireSettingsLockAsync(
|
||||
* never yields, so a concurrent waiter cannot interleave between the read and
|
||||
* the write, and the lock is released the moment the mutation completes.
|
||||
*/
|
||||
function runLockedSettingsMutation<T>(
|
||||
lock: AcquiredSettingsLock,
|
||||
filePath: string,
|
||||
mutator: McpSettingsMutator<T>,
|
||||
): T {
|
||||
function runLockedSettingsMutation<T>(lock: AcquiredSettingsLock, filePath: string, mutator: McpSettingsMutator<T>): T {
|
||||
try {
|
||||
const settings = loadRawSettingsObject(filePath);
|
||||
const result = runPureSettingsMutator(settings, mutator);
|
||||
@@ -517,20 +495,13 @@ export async function updateMcpSettingsFile<T>(
|
||||
*/
|
||||
function loadRawSettingsObject(filePath: string): Record<string, unknown> {
|
||||
const settings = readJsonObjectOrEmpty(filePath);
|
||||
if (
|
||||
!settings.mcpServers ||
|
||||
typeof settings.mcpServers !== "object" ||
|
||||
Array.isArray(settings.mcpServers)
|
||||
) {
|
||||
if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) {
|
||||
settings.mcpServers = {};
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function runPureSettingsMutator<T>(
|
||||
settings: Record<string, unknown>,
|
||||
mutator: McpSettingsMutator<T>,
|
||||
): T {
|
||||
function runPureSettingsMutator<T>(settings: Record<string, unknown>, mutator: McpSettingsMutator<T>): T {
|
||||
const before = JSON.stringify(settings);
|
||||
const shadow = JSON.parse(before) as Record<string, unknown>;
|
||||
const shadowResult = mutator(shadow);
|
||||
@@ -771,10 +742,7 @@ export function updateMcpServerOAuthState(
|
||||
options: LoadMcpSettingsOptions = {},
|
||||
): McpServerOAuthState {
|
||||
const filePath = options.filePath ?? resolveDefaultMcpSettingsPath();
|
||||
return updateMcpSettingsFileSync(
|
||||
filePath,
|
||||
buildOAuthStateMutator(serverName, updater),
|
||||
);
|
||||
return updateMcpSettingsFileSync(filePath, buildOAuthStateMutator(serverName, updater));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -788,10 +756,7 @@ export async function updateMcpServerOAuthStateAsync(
|
||||
options: LoadMcpSettingsOptions = {},
|
||||
): Promise<McpServerOAuthState> {
|
||||
const filePath = options.filePath ?? resolveDefaultMcpSettingsPath();
|
||||
return updateMcpSettingsFile(
|
||||
filePath,
|
||||
buildOAuthStateMutator(serverName, updater),
|
||||
);
|
||||
return updateMcpSettingsFile(filePath, buildOAuthStateMutator(serverName, updater));
|
||||
}
|
||||
|
||||
export function listMcpServerOAuthStatuses(
|
||||
|
||||
@@ -16,19 +16,6 @@ import { RUN_COMMAND_QUERY_PREVIEW_LIMIT, TimeoutError } from "./helpers";
|
||||
import { INPUT_ARG_CHAR_LIMIT } from "./schemas";
|
||||
import type { SkillsExecutorWithMetadata } from "./types";
|
||||
|
||||
function hasSchemaKey(value: unknown, key: string): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => hasSchemaKey(item, key));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).some(
|
||||
([entryKey, entryValue]) =>
|
||||
entryKey === key || hasSchemaKey(entryValue, key),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createMockSkillsExecutor(
|
||||
fn: (...args: unknown[]) => Promise<string> = async () => "ok",
|
||||
configuredSkills?: SkillsExecutorWithMetadata["configuredSkills"],
|
||||
@@ -623,62 +610,6 @@ describe("default run_commands tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts mixed structured and string command arrays", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string; args?: string[] }) =>
|
||||
typeof command === "string"
|
||||
? `ran:${command}`
|
||||
: `ran:${command.command}:${(command.args ?? []).join(",")}`,
|
||||
);
|
||||
const tool = createShellTool(execute);
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
commands: ["pwd", { command: "node", args: ["--version"] }],
|
||||
} as never,
|
||||
{
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ query: "pwd", result: "ran:pwd", success: true },
|
||||
{
|
||||
query: "node --version",
|
||||
result: "ran:node:--version",
|
||||
success: true,
|
||||
},
|
||||
]);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"pwd",
|
||||
process.cwd(),
|
||||
expect.objectContaining({ iteration: 1 }),
|
||||
);
|
||||
expect(execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ command: "node", args: ["--version"] },
|
||||
process.cwd(),
|
||||
expect.objectContaining({ iteration: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid text-object command entries", async () => {
|
||||
const execute = vi.fn(async () => "ran");
|
||||
const tool = createShellTool(execute);
|
||||
|
||||
await expect(
|
||||
tool.execute({ commands: [{ $text: "pwd" }] } as never, {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
}),
|
||||
).rejects.toThrow("Invalid input");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves args on direct structured command objects", async () => {
|
||||
const execute = vi.fn(
|
||||
async (command: string | { command: string; args?: string[] }) =>
|
||||
@@ -1564,22 +1495,6 @@ describe("default read_files tool", () => {
|
||||
});
|
||||
|
||||
describe("zod schema conversion", () => {
|
||||
it("advertises run_commands as string-only command arrays", () => {
|
||||
const tool = createShellTool(async () => "ok");
|
||||
const inputSchema = tool.inputSchema as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(inputSchema);
|
||||
|
||||
expect(serialized).not.toContain('"anyOf"');
|
||||
expect(serialized).not.toContain("Prefer structured");
|
||||
expect(hasSchemaKey(inputSchema, "command")).toBe(false);
|
||||
|
||||
const properties = inputSchema.properties as Record<string, unknown>;
|
||||
const commands = properties.commands as {
|
||||
items?: { type?: string };
|
||||
};
|
||||
expect(commands.items?.type).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves read_files required properties in generated JSON schema", () => {
|
||||
const tool = createReadFilesTool(async () => "ok");
|
||||
const inputSchema = tool.inputSchema as Record<string, unknown>;
|
||||
|
||||
@@ -49,8 +49,8 @@ import {
|
||||
SearchCodebaseInputSchema,
|
||||
SearchCodebaseUnionInputSchema,
|
||||
type SkillsInput,
|
||||
SkillsInputSchema,
|
||||
type StructuredCommandInput,
|
||||
SkillsInputSchema,
|
||||
type SubmitInput,
|
||||
SubmitInputSchema,
|
||||
} from "./schemas";
|
||||
@@ -415,7 +415,7 @@ export function createShellTool(
|
||||
? "Run non-interactive shell commands from the root of the workspace in Windows environment. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
|
||||
"Commands run through PowerShell; quote paths and arguments for PowerShell and use ';' to sequence commands. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
"Prefer structured { command, args } entries for portability; plain string commands should be properly shell-escaped. Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
|
||||
: "Run non-interactive shell commands from the root of the workspace. " +
|
||||
RUN_COMMANDS_SHARED_INSTRUCTIONS +
|
||||
"Commands should be properly shell-escaped and targeted to avoid error or timeout. Include multiple commands in the same call when they are independent complete shell commands and safe to run concurrently; multiline scripts and heredocs must be a single command string. When independent reads, searches, or edits are also needed, call those tools in the same response. " +
|
||||
|
||||
@@ -120,14 +120,19 @@ export const StructuredCommandEntrySchema = z.union([
|
||||
StructuredCommandInputSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Schema for run_commands tool input.
|
||||
*
|
||||
* Supports both shell strings and direct structured `{ command, args }` entries
|
||||
* on every platform. Plain strings are interpreted by the active shell;
|
||||
* structured entries bypass shell parsing and execute the command directly.
|
||||
*/
|
||||
export const RunCommandsInputSchema = z.object({
|
||||
commands: z
|
||||
.array(CommandInputSchema)
|
||||
.describe("Array of complete shell command strings to execute."),
|
||||
});
|
||||
|
||||
const StructuredCommandsInputSchema = z.object({
|
||||
commands: z.array(StructuredCommandEntrySchema),
|
||||
.array(StructuredCommandEntrySchema)
|
||||
.describe(
|
||||
"Array of commands to execute. Prefer structured { command, args } entries for portability; plain strings are still supported and are interpreted by the active shell.",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -135,7 +140,6 @@ const StructuredCommandsInputSchema = z.object({
|
||||
*/
|
||||
export const RunCommandsInputUnionSchema = z.union([
|
||||
RunCommandsInputSchema,
|
||||
StructuredCommandsInputSchema,
|
||||
z.object({ commands: StructuredCommandEntrySchema }),
|
||||
z.array(StructuredCommandInputSchema),
|
||||
StructuredCommandInputSchema,
|
||||
|
||||
@@ -481,19 +481,16 @@ export {
|
||||
isPluginDisabledGlobally,
|
||||
isTelemetryOptedOutGlobally,
|
||||
isToolDisabledGlobally,
|
||||
readCompactionStrategyGlobally,
|
||||
readGlobalSettings,
|
||||
resolveDisabledPluginPaths,
|
||||
resolveDisabledToolNames,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { GlobalCompactionStrategy } from "./services/global-settings";
|
||||
export type {
|
||||
McpInstallOptions,
|
||||
McpInstallResult,
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { ITelemetryService } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GlobalSettingsSchema,
|
||||
readCompactionStrategyGlobally,
|
||||
readGlobalSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
@@ -46,13 +44,11 @@ describe("global-settings", () => {
|
||||
});
|
||||
expect(
|
||||
GlobalSettingsSchema.parse({
|
||||
compactionStrategy: "agentic",
|
||||
disabledTools: ["read_files"],
|
||||
extra: true,
|
||||
}),
|
||||
).toEqual({
|
||||
autoUpdateEnabled: true,
|
||||
compactionStrategy: "agentic",
|
||||
disabledTools: ["read_files"],
|
||||
telemetryOptOut: false,
|
||||
});
|
||||
@@ -203,20 +199,6 @@ describe("global-settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads and writes the compaction strategy globally", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
|
||||
try {
|
||||
const settingsPath = join(root, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
|
||||
expect(readCompactionStrategyGlobally()).toBe("basic");
|
||||
setCompactionStrategyGlobally("agentic");
|
||||
expect(readCompactionStrategyGlobally()).toBe("agentic");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("invalidates the cache when writeGlobalSettings is called", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
|
||||
|
||||
@@ -29,19 +29,10 @@ const GlobalSettingsStringListSchema = z
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
});
|
||||
|
||||
const GlobalCompactionStrategySchema = z
|
||||
.enum(["basic", "agentic"])
|
||||
.catch("basic");
|
||||
|
||||
export type GlobalCompactionStrategy = z.infer<
|
||||
typeof GlobalCompactionStrategySchema
|
||||
>;
|
||||
|
||||
export const GlobalSettingsSchema = z
|
||||
.object({
|
||||
telemetryOptOut: z.boolean().default(false).catch(false),
|
||||
autoUpdateEnabled: z.boolean().default(true).catch(true),
|
||||
compactionStrategy: GlobalCompactionStrategySchema.optional(),
|
||||
disabledTools: GlobalSettingsStringListSchema.optional(),
|
||||
disabledPlugins: GlobalSettingsStringListSchema.optional(),
|
||||
})
|
||||
@@ -50,16 +41,12 @@ export const GlobalSettingsSchema = z
|
||||
const normalized: {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
compactionStrategy?: GlobalCompactionStrategy;
|
||||
disabledTools?: string[];
|
||||
disabledPlugins?: string[];
|
||||
} = {
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
telemetryOptOut: settings.telemetryOptOut,
|
||||
};
|
||||
if (settings.compactionStrategy) {
|
||||
normalized.compactionStrategy = settings.compactionStrategy;
|
||||
}
|
||||
if (settings.disabledTools?.length) {
|
||||
normalized.disabledTools = settings.disabledTools;
|
||||
}
|
||||
@@ -193,16 +180,6 @@ export function setAutoUpdateEnabledGlobally(
|
||||
);
|
||||
}
|
||||
|
||||
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
|
||||
return readGlobalSettings().compactionStrategy ?? "basic";
|
||||
}
|
||||
|
||||
export function setCompactionStrategyGlobally(
|
||||
compactionStrategy: GlobalCompactionStrategy,
|
||||
): void {
|
||||
writeGlobalSettings({ ...readGlobalSettings(), compactionStrategy });
|
||||
}
|
||||
|
||||
export function resolveDisabledToolNames(
|
||||
disabledToolNames?: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
|
||||
@@ -64,35 +64,35 @@ const baseRequest: AgentModelRequest = {
|
||||
|
||||
describe("createAgentModelFromApiHandler", () => {
|
||||
it("maps text + usage chunks to events and appends a finish", async () => {
|
||||
const handler = fakeHandler([
|
||||
{ type: "text", text: "hello", id: "x" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
id: "x",
|
||||
},
|
||||
]);
|
||||
const handler = fakeHandler([
|
||||
{ type: "text", text: "hello", id: "x" },
|
||||
{
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
id: "x",
|
||||
},
|
||||
]);
|
||||
const model = createAgentModelFromApiHandler(handler);
|
||||
const events = await collect(model.stream(baseRequest));
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "text-delta", text: "hello" },
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
{
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
reasoningTokenCount: 2,
|
||||
totalCost: 0.01,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "finish", reason: "stop" },
|
||||
]);
|
||||
{ type: "finish", reason: "stop" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps tool_calls (object args) to a tool-call-delta event", async () => {
|
||||
|
||||
@@ -168,8 +168,8 @@ describe("resolveProviderConfig", () => {
|
||||
expect(resolved?.knownModels?.["zai/glm-5.2"]).toMatchObject({
|
||||
id: "zai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
contextWindow: 1_040_000,
|
||||
maxInputTokens: 1_040_000,
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 1_000_000,
|
||||
});
|
||||
expect(resolved?.knownModels?.["z-ai/glm-5.2"]).toBeUndefined();
|
||||
});
|
||||
@@ -196,8 +196,8 @@ describe("resolveProviderConfig", () => {
|
||||
});
|
||||
expect(resolved?.knownModels?.["zai/glm-5.2"]).toMatchObject({
|
||||
id: "zai/glm-5.2",
|
||||
contextWindow: 1_040_000,
|
||||
maxInputTokens: 1_040_000,
|
||||
contextWindow: 1_000_000,
|
||||
maxInputTokens: 1_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,7 +114,14 @@ describe("marketplace service", () => {
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npx",
|
||||
args: ["-y", "skills@latest", "remove", "review-team", "-g", "-y"],
|
||||
args: [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"review-team",
|
||||
"-g",
|
||||
"-y",
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type LegacyClineUserInfo,
|
||||
@@ -147,13 +146,9 @@ describe("migrateLegacyProviderSettings", () => {
|
||||
expect(manager.getProviderSettings("openai")?.apiKey).toBe(
|
||||
"already-migrated",
|
||||
);
|
||||
const anthropicDefault =
|
||||
LlmsModels.getProviderCollectionSync("anthropic")?.provider
|
||||
.defaultModelId;
|
||||
expect(anthropicDefault).toBeDefined();
|
||||
expect(manager.getProviderSettings("anthropic")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: anthropicDefault,
|
||||
model: "claude-fable-5",
|
||||
apiKey: "legacy-key",
|
||||
});
|
||||
expect(manager.read().providers.openai?.tokenSource).toBe("manual");
|
||||
|
||||
@@ -416,12 +416,6 @@ function resolveLegacyCodexAuth(
|
||||
|
||||
function getDefaultModelForProvider(providerId: string): string | undefined {
|
||||
const builtInModels = LlmsModels.getGeneratedModelsForProvider(providerId);
|
||||
const providerCollection = LlmsModels.getProviderCollectionSync(providerId);
|
||||
const defaultModelId = providerCollection?.provider.defaultModelId;
|
||||
if (defaultModelId && builtInModels[defaultModelId]) {
|
||||
return defaultModelId;
|
||||
}
|
||||
|
||||
const firstModelId = Object.keys(builtInModels)[0];
|
||||
return firstModelId ?? undefined;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user