Custom modes

This commit is contained in:
Matt Rubens
2025-01-18 03:39:26 -05:00
parent 332245c33a
commit b8e0aa0cde
65 changed files with 3749 additions and 1531 deletions

View File

@@ -4,6 +4,8 @@ import { ApiConfiguration, ModelInfo } from "../../shared/api"
import { ApiStreamChunk } from "../../api/transform/stream"
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import * as os from "os"
import * as path from "path"
// Mock all MCP-related modules
jest.mock(
@@ -209,6 +211,9 @@ describe("Cline", () => {
beforeEach(() => {
// Setup mock extension context
const storageUri = {
fsPath: path.join(os.tmpdir(), "test-storage"),
}
mockExtensionContext = {
globalState: {
get: jest.fn().mockImplementation((key) => {
@@ -231,6 +236,7 @@ describe("Cline", () => {
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
keys: jest.fn().mockReturnValue([]),
},
globalStorageUri: storageUri,
workspaceState: {
get: jest.fn().mockImplementation((key) => undefined),
update: jest.fn().mockImplementation((key, value) => Promise.resolve()),
@@ -244,9 +250,6 @@ describe("Cline", () => {
extensionUri: {
fsPath: "/mock/extension/path",
},
globalStorageUri: {
fsPath: "/mock/storage/path",
},
extension: {
packageJSON: {
version: "1.0.0",
@@ -425,27 +428,34 @@ describe("Cline", () => {
// Mock the API's createMessage method to capture the conversation history
const createMessageSpy = jest.fn()
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "" }
},
async next() {
return { done: true, value: undefined }
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
async [Symbol.asyncDispose]() {
// Cleanup
},
} as AsyncGenerator<ApiStreamChunk>
// Set up mock stream
const mockStreamForClean = (async function* () {
yield { type: "text", text: "test response" }
})()
jest.spyOn(cline.api, "createMessage").mockImplementation((...args) => {
createMessageSpy(...args)
return mockStream
// Set up spy
const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean)
jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
// Mock getEnvironmentDetails to return empty details
jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("")
// Mock loadContext to return unmodified content
jest.spyOn(cline as any, "loadContext").mockImplementation(async (content) => [content, ""])
// Add test message to conversation history
cline.apiConversationHistory = [
{
role: "user" as const,
content: [{ type: "text" as const, text: "test message" }],
ts: Date.now(),
},
]
// Mock abort state
Object.defineProperty(cline, "abort", {
get: () => false,
configurable: true,
})
// Add a message with extra properties to the conversation history
@@ -458,30 +468,25 @@ describe("Cline", () => {
cline.apiConversationHistory = [messageWithExtra]
// Trigger an API request
await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false)
// Get all calls to createMessage
const calls = createMessageSpy.mock.calls
// Get the conversation history from the first API call
const history = cleanMessageSpy.mock.calls[0][1]
expect(history).toBeDefined()
expect(history.length).toBeGreaterThan(0)
// Find the call that includes our test message
const relevantCall = calls.find((call) =>
call[1]?.some((msg: any) => msg.content?.[0]?.text === "test message"),
)
// Verify the conversation history was cleaned in the relevant call
expect(relevantCall?.[1]).toEqual(
expect.arrayContaining([
{
role: "user",
content: [{ type: "text", text: "test message" }],
},
]),
// Find our test message
const cleanedMessage = history.find((msg: { content?: Array<{ text: string }> }) =>
msg.content?.some((content) => content.text === "test message"),
)
expect(cleanedMessage).toBeDefined()
expect(cleanedMessage).toEqual({
role: "user",
content: [{ type: "text", text: "test message" }],
})
// Verify extra properties were removed
const passedMessage = relevantCall?.[1].find((msg: any) => msg.content?.[0]?.text === "test message")
expect(passedMessage).not.toHaveProperty("ts")
expect(passedMessage).not.toHaveProperty("extraProp")
expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"])
})
it("should handle image blocks based on model capabilities", async () => {
@@ -573,41 +578,68 @@ describe("Cline", () => {
})
clineWithoutImages.apiConversationHistory = conversationHistory
// Create message spy for both instances
const createMessageSpyWithImages = jest.fn()
const createMessageSpyWithoutImages = jest.fn()
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "" }
// Mock abort state for both instances
Object.defineProperty(clineWithImages, "abort", {
get: () => false,
configurable: true,
})
Object.defineProperty(clineWithoutImages, "abort", {
get: () => false,
configurable: true,
})
// Mock environment details and context loading
jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("")
jest.spyOn(clineWithImages as any, "loadContext").mockImplementation(async (content) => [content, ""])
jest.spyOn(clineWithoutImages as any, "loadContext").mockImplementation(async (content) => [
content,
"",
])
// Set up mock streams
const mockStreamWithImages = (async function* () {
yield { type: "text", text: "test response" }
})()
const mockStreamWithoutImages = (async function* () {
yield { type: "text", text: "test response" }
})()
// Set up spies
const imagesSpy = jest.fn().mockReturnValue(mockStreamWithImages)
const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages)
jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
// Set up conversation history with images
clineWithImages.apiConversationHistory = [
{
role: "user",
content: [
{ type: "text", text: "Here is an image" },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
],
},
} as AsyncGenerator<ApiStreamChunk>
]
jest.spyOn(clineWithImages.api, "createMessage").mockImplementation((...args) => {
createMessageSpyWithImages(...args)
return mockStream
})
jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation((...args) => {
createMessageSpyWithoutImages(...args)
return mockStream
})
// Trigger API requests
await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
// Trigger API requests for both instances
await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test" }])
await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test" }])
// Get the calls
const imagesCalls = imagesSpy.mock.calls
const noImagesCalls = noImagesSpy.mock.calls
// Verify model with image support preserves image blocks
const callsWithImages = createMessageSpyWithImages.mock.calls
const historyWithImages = callsWithImages[0][1][0]
expect(historyWithImages.content).toHaveLength(2)
expect(historyWithImages.content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(historyWithImages.content[1]).toHaveProperty("type", "image")
expect(imagesCalls[0][1][0].content).toHaveLength(2)
expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
// Verify model without image support converts image blocks to text
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls
const historyWithoutImages = callsWithoutImages[0][1][0]
expect(historyWithoutImages.content).toHaveLength(2)
expect(historyWithoutImages.content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(historyWithoutImages.content[1]).toEqual({
expect(noImagesCalls[0][1][0].content).toHaveLength(2)
expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
expect(noImagesCalls[0][1][0].content[1]).toEqual({
type: "text",
text: "[Referenced image in conversation]",
})

View File

@@ -1,7 +1,6 @@
import { Mode, isToolAllowedForMode, TestToolName, getModeConfig, modes } from "../../shared/modes"
import { Mode, isToolAllowedForMode, getModeConfig, modes } from "../../shared/modes"
import { validateToolUse } from "../mode-validator"
const asTestTool = (tool: string): TestToolName => tool as TestToolName
import { TOOL_GROUPS } from "../../shared/tool-groups"
const [codeMode, architectMode, askMode] = modes.map((mode) => mode.slug)
describe("mode-validator", () => {
@@ -9,21 +8,26 @@ describe("mode-validator", () => {
describe("code mode", () => {
it("allows all code mode tools", () => {
const mode = getModeConfig(codeMode)
mode.tools.forEach(([tool]) => {
expect(isToolAllowedForMode(tool, codeMode)).toBe(true)
// Code mode has all groups
Object.entries(TOOL_GROUPS).forEach(([_, tools]) => {
tools.forEach((tool) => {
expect(isToolAllowedForMode(tool, codeMode, [])).toBe(true)
})
})
})
it("disallows unknown tools", () => {
expect(isToolAllowedForMode(asTestTool("unknown_tool"), codeMode)).toBe(false)
expect(isToolAllowedForMode("unknown_tool" as any, codeMode, [])).toBe(false)
})
})
describe("architect mode", () => {
it("allows configured tools", () => {
const mode = getModeConfig(architectMode)
mode.tools.forEach(([tool]) => {
expect(isToolAllowedForMode(tool, architectMode)).toBe(true)
// Architect mode has read, browser, and mcp groups
const architectTools = [...TOOL_GROUPS.read, ...TOOL_GROUPS.browser, ...TOOL_GROUPS.mcp]
architectTools.forEach((tool) => {
expect(isToolAllowedForMode(tool, architectMode, [])).toBe(true)
})
})
})
@@ -31,22 +35,57 @@ describe("mode-validator", () => {
describe("ask mode", () => {
it("allows configured tools", () => {
const mode = getModeConfig(askMode)
mode.tools.forEach(([tool]) => {
expect(isToolAllowedForMode(tool, askMode)).toBe(true)
// Ask mode has read, browser, and mcp groups
const askTools = [...TOOL_GROUPS.read, ...TOOL_GROUPS.browser, ...TOOL_GROUPS.mcp]
askTools.forEach((tool) => {
expect(isToolAllowedForMode(tool, askMode, [])).toBe(true)
})
})
})
describe("custom modes", () => {
it("allows tools from custom mode configuration", () => {
const customModes = [
{
slug: "custom-mode",
name: "Custom Mode",
roleDefinition: "Custom role",
groups: ["read", "edit"] as const,
},
]
// Should allow tools from read and edit groups
expect(isToolAllowedForMode("read_file", "custom-mode", customModes)).toBe(true)
expect(isToolAllowedForMode("write_to_file", "custom-mode", customModes)).toBe(true)
// Should not allow tools from other groups
expect(isToolAllowedForMode("execute_command", "custom-mode", customModes)).toBe(false)
})
it("allows custom mode to override built-in mode", () => {
const customModes = [
{
slug: codeMode,
name: "Custom Code Mode",
roleDefinition: "Custom role",
groups: ["read"] as const,
},
]
// Should allow tools from read group
expect(isToolAllowedForMode("read_file", codeMode, customModes)).toBe(true)
// Should not allow tools from other groups
expect(isToolAllowedForMode("write_to_file", codeMode, customModes)).toBe(false)
})
})
})
describe("validateToolUse", () => {
it("throws error for disallowed tools in architect mode", () => {
expect(() => validateToolUse("unknown_tool", "architect")).toThrow(
expect(() => validateToolUse("unknown_tool" as any, "architect", [])).toThrow(
'Tool "unknown_tool" is not allowed in architect mode.',
)
})
it("does not throw for allowed tools in architect mode", () => {
expect(() => validateToolUse("read_file", "architect")).not.toThrow()
expect(() => validateToolUse("read_file", "architect", [])).not.toThrow()
})
})
})