mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
fix(vscode): include rich workspace metadata in system prompt (#13518)
* capture richer workspace information for vs code extension * fix(shared): redact credentials from workspace remotes * fix(shared): avoid regex backtracking in remote redaction --------- Co-authored-by: Max Paulus 🥪 <max@cline.bot>
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
|
||||
|
||||
const workspaceDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of workspaceDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildUserInputMessage", () => {
|
||||
it("extracts image mentions into userImages", async () => {
|
||||
@@ -43,3 +52,39 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
it("includes git remotes and the latest commit for Cline requests", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
execFileSync("git", ["init"], { cwd });
|
||||
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
|
||||
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
|
||||
writeFileSync(join(cwd, "README.md"), "test\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd });
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
|
||||
expect(prompt).toContain(commit);
|
||||
});
|
||||
|
||||
it("includes parseable metadata outside a project", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
|
||||
workspaceDirectories.push(cwd);
|
||||
|
||||
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
|
||||
|
||||
expect(prompt).toContain("# Workspace Configuration");
|
||||
expect(prompt).toContain(JSON.stringify(cwd));
|
||||
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
|
||||
expect(prompt).not.toContain("associatedRemoteUrls");
|
||||
expect(prompt).not.toContain("latestGitCommitHash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,6 +355,8 @@ describe("buildSessionConfig", () => {
|
||||
|
||||
expect(config.providerId).toBe("cline")
|
||||
expect(config.apiKey).toBe("workos:test-access-token")
|
||||
expect(config.systemPrompt).toContain("# Workspace Configuration")
|
||||
expect(config.systemPrompt).toContain(JSON.stringify("/tmp/workspace"))
|
||||
})
|
||||
|
||||
it("resolves ClinePass from the shared Cline OAuth credentials", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// The factory does NOT handle UI concerns — that's the SdkController's job.
|
||||
|
||||
import {
|
||||
buildWorkspaceMetadata,
|
||||
type ClineCoreStartInput,
|
||||
type CoreSessionConfig,
|
||||
getProviderAuthHandler,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
MODEL_COLLECTIONS_BY_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@cline/llms"
|
||||
import { buildClineSystemPrompt } from "@cline/shared"
|
||||
import { buildClineSystemPrompt, isClineProvider } from "@cline/shared"
|
||||
import type { ApiConfiguration } from "@shared/api"
|
||||
import { ClineClient } from "@shared/cline"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
@@ -901,10 +902,17 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
|
||||
: resolveProviderReasoningConfig(providerId)
|
||||
|
||||
// Build the system prompt using the shared prompt builder. Core still
|
||||
// expects callers to provide a concrete systemPrompt, but the prompt builder
|
||||
// can derive baseline workspace context from the root path and workspace
|
||||
// name, so we avoid duplicating core's richer workspace metadata pass here.
|
||||
// Include rich workspace metadata so Cline API observability can extract
|
||||
// git remotes and the latest commit hash from the system message.
|
||||
let workspaceMetadata: string | undefined
|
||||
if (isClineProvider(providerId)) {
|
||||
try {
|
||||
workspaceMetadata = await buildWorkspaceMetadata(workspaceRoot)
|
||||
} catch (error) {
|
||||
Logger.warn("[SessionFactory] Failed to build workspace metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
let systemPrompt = ""
|
||||
try {
|
||||
const workspaceName = resolveWorkspaceName(cwd)
|
||||
@@ -912,6 +920,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
ide: "VS Code",
|
||||
workspaceRoot,
|
||||
workspaceName,
|
||||
metadata: workspaceMetadata,
|
||||
mode: mode === "plan" ? "plan" : "act",
|
||||
providerId,
|
||||
platform: process.platform,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MODE_TAG_INSTRUCTIONS,
|
||||
PLAN_MODE_INSTRUCTIONS,
|
||||
PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH,
|
||||
processWorkspaceInfo,
|
||||
} from "./cline";
|
||||
|
||||
const BASE_OPTIONS = {
|
||||
@@ -13,6 +14,29 @@ const BASE_OPTIONS = {
|
||||
platform: "linux",
|
||||
};
|
||||
|
||||
describe("processWorkspaceInfo", () => {
|
||||
it("redacts URL credentials while preserving SCP-style SSH remotes", () => {
|
||||
const metadata = JSON.parse(
|
||||
processWorkspaceInfo({
|
||||
rootPath: "/workspace/project",
|
||||
associatedRemoteUrls: [
|
||||
"origin: https://user:token@github.com/cline/cline.git",
|
||||
"backup: ssh://git:secret@example.com/cline/cline.git",
|
||||
"mirror: git@github.com:cline/cline.git",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
metadata.workspaces["/workspace/project"].associatedRemoteUrls,
|
||||
).toEqual([
|
||||
"origin: https://github.com/cline/cline.git",
|
||||
"backup: ssh://example.com/cline/cline.git",
|
||||
"mirror: git@github.com:cline/cline.git",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClineSystemPrompt mode instructions", () => {
|
||||
it("explains the user_input mode attribute in act mode", () => {
|
||||
const prompt = buildClineSystemPrompt({ ...BASE_OPTIONS, mode: "act" });
|
||||
@@ -78,6 +102,27 @@ describe("buildClineSystemPrompt mode instructions", () => {
|
||||
expect(rulesIndex).toBeLessThan(prompt.indexOf(MODE_TAG_INSTRUCTIONS));
|
||||
});
|
||||
|
||||
it("includes rich workspace metadata for the Cline backend parser", () => {
|
||||
const metadata = JSON.stringify({
|
||||
workspaces: {
|
||||
"/workspace/project": {
|
||||
hint: "project",
|
||||
associatedRemoteUrls: [
|
||||
"origin: https://github.com/cline/cline.git",
|
||||
],
|
||||
latestGitCommitHash: "abc123",
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = buildClineSystemPrompt({
|
||||
...BASE_OPTIONS,
|
||||
providerId: "cline",
|
||||
metadata,
|
||||
});
|
||||
|
||||
expect(prompt).toContain(`# Workspace Configuration\n${metadata}`);
|
||||
});
|
||||
|
||||
it("respects an explicit override prompt without injecting mode sections", () => {
|
||||
const prompt = buildClineSystemPrompt({
|
||||
...BASE_OPTIONS,
|
||||
|
||||
@@ -58,13 +58,39 @@ export const PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH = `${PLAN_MODE_INSTRUCTIONS_BA
|
||||
|
||||
Once you have presented your plan, end your turn and wait for the user's response. You do NOT have the ability to switch to act mode yourself -- the user must do it manually with the Plan/Act toggle once they are satisfied with the plan. If the task requires tools that are only available in act mode, ask the user to "toggle to Act mode" (use those words).`;
|
||||
|
||||
function redactRemoteUrlCredentials(remote: string): string {
|
||||
const schemeEnd = remote.indexOf("://");
|
||||
if (schemeEnd < 1) return remote;
|
||||
|
||||
const authorityStart = schemeEnd + 3;
|
||||
let authorityEnd = authorityStart;
|
||||
while (authorityEnd < remote.length) {
|
||||
const char = remote[authorityEnd];
|
||||
if (
|
||||
char === "/" ||
|
||||
char === "?" ||
|
||||
char === "#" ||
|
||||
char.charCodeAt(0) <= 32
|
||||
) {
|
||||
break;
|
||||
}
|
||||
authorityEnd++;
|
||||
}
|
||||
|
||||
const userInfoEnd = remote.lastIndexOf("@", authorityEnd - 1);
|
||||
if (userInfoEnd < authorityStart) return remote;
|
||||
return remote.slice(0, authorityStart) + remote.slice(userInfoEnd + 1);
|
||||
}
|
||||
|
||||
export function processWorkspaceInfo(info: WorkspaceInfo): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
workspaces: {
|
||||
[info.rootPath]: {
|
||||
hint: info.hint,
|
||||
associatedRemoteUrls: info.associatedRemoteUrls,
|
||||
associatedRemoteUrls: info.associatedRemoteUrls?.map(
|
||||
redactRemoteUrlCredentials,
|
||||
),
|
||||
latestGitCommitHash: info.latestGitCommitHash,
|
||||
latestGitBranchName: info.latestGitBranchName,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user