Compare commits

..
Author SHA1 Message Date
abeatrix e40a9d6072 feat: add support for JSON-encoded string arrays for tools
- Added normalizeJsonLikeSearchCodebaseInput and normalizeJsonLikeReadFilesInput helpers
- Updated createSearchTool and createReadFilesTool to accept queries/files as JSON-encoded string arrays
- Refactored normalizeJsonLikeRunCommandsInput into a generic normalizeJsonLikeToolInput utility
- Added test coverage for new input format acceptance
2026-06-24 14:29:14 -07:00
abeatrix d270941bcd keeps union validation 2026-06-24 13:45:16 -07:00
abeatrix 86e8b61206 fix(tools): parse JSON-encoded command arrays in shell tools
Handle cases where the `commands` field is passed as a JSON-encoded
string array instead of a native array. Refactor input handling in
`createBashTool` and `createWindowsShellTool` to support both string
and structured command inputs, and update `coalesceSplitHeredocCommands`
to skip non-string entries. Removes the `RunCommandsInputUnionSchema`
in favor of direct validation.
2026-06-24 12:53:30 -07:00
15 changed files with 284 additions and 500 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.0.1",
"version": "3.89.2",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
-2
View File
@@ -921,7 +921,6 @@ export class Controller {
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
const modelsDevProviderModels = this.stateManager.getModelsDevProviderModelsCache() ?? undefined
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -970,7 +969,6 @@ export class Controller {
isNewUser,
welcomeViewCompleted,
onboardingModels,
modelsDevProviderModels,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -1,84 +0,0 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { applyModelsDevProviderModels, type ModelsDevProviderModels, normalizeModelsDevProviderModels } from "@shared/models-dev"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { StateManager } from "@/core/storage/StateManager"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
export const MODELS_DEV_CATALOG_URL = "https://models.dev/api.json"
let pendingRefresh: Promise<ModelsDevProviderModels> | null = null
export async function refreshModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const cache = StateManager.get().getModelsDevProviderModelsCache()
if (cache) {
applyModelsDevProviderModels(cache)
return cache
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheModelsDevProviderModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const modelsDevProviderModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.modelsDevProviderModels)
let providerModels: ModelsDevProviderModels = {}
try {
const response = await axios.get(MODELS_DEV_CATALOG_URL, getAxiosSettings())
providerModels = normalizeModelsDevProviderModels(response.data)
if (Object.keys(providerModels).length === 0) {
throw new Error("No supported models.dev provider models found")
}
await fs.writeFile(modelsDevProviderModelsFilePath, JSON.stringify(providerModels))
Logger.log("models.dev provider models fetched and saved")
} catch (error) {
Logger.error("Error fetching models.dev provider models:", error)
const cachedModels = await readModelsDevProviderModelsFromCache()
if (cachedModels && Object.keys(cachedModels).length > 0) {
providerModels = cachedModels
Logger.log("Loaded models.dev provider models from cache")
}
}
if (Object.keys(providerModels).length > 0) {
applyModelsDevProviderModels(providerModels)
StateManager.get().setModelsDevProviderModelsCache(providerModels)
}
return providerModels
}
export async function readModelsDevProviderModelsFromCache(): Promise<ModelsDevProviderModels | undefined> {
try {
const modelsDevProviderModelsFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.modelsDevProviderModels,
)
const fileExists = await fileExistsAtPath(modelsDevProviderModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(modelsDevProviderModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
} catch (error) {
Logger.error("Error reading cached models.dev provider models:", error)
}
return undefined
}
@@ -11,7 +11,6 @@ import { refreshClineModels } from "../models/refreshClineModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshHicapModels } from "../models/refreshHicapModels"
import { refreshLiteLlmModels } from "../models/refreshLiteLlmModels"
import { refreshModelsDevProviderModels } from "../models/refreshModelsDevProviderModels"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
@@ -29,14 +28,6 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
}
refreshModelsDevProviderModels()
.then(async (models) => {
if (models && Object.keys(models).length > 0) {
await controller.postStateToWebview()
}
})
.catch((error) => Logger.error("Failed to refresh models.dev provider models:", error))
// Refresh OpenRouter models from API
refreshOpenRouterModels(controller).then(async (models) => {
if (models && Object.keys(models).length > 0) {
@@ -1,5 +1,4 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import type { ModelsDevProviderModels } from "@shared/models-dev"
import {
ApiHandlerSettingsKeys,
type GlobalState,
@@ -103,7 +102,6 @@ export class StateManager {
liteLlmModels: null,
vercelModels: null,
}
private modelsDevProviderModelsCache: { data: ModelsDevProviderModels; timestamp: number } | null = null
// Debounced persistence state
private pendingGlobalState = new Set<GlobalStateAndSettingsKey>()
@@ -504,24 +502,6 @@ export class StateManager {
return cached.data
}
setModelsDevProviderModelsCache(models: ModelsDevProviderModels): void {
this.modelsDevProviderModelsCache = { data: models, timestamp: Date.now() }
}
getModelsDevProviderModelsCache(): ModelsDevProviderModels | null {
const cached = this.modelsDevProviderModelsCache
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > this.MODEL_CACHE_TTL_MS) {
this.modelsDevProviderModelsCache = null
return null
}
return cached.data
}
/**
* Get model info by provider and model ID (from in-memory cache)
*/
-1
View File
@@ -51,7 +51,6 @@ export const GlobalFileNames = {
uiMessages: "ui_messages.json",
clineRecommendedModels: "cline_recommended_models.json",
clineModels: "cline_models.json",
modelsDevProviderModels: "models_dev_provider_models.json",
openRouterModels: "openrouter_models.json",
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
groqModels: "groq_models.json",
@@ -13,7 +13,6 @@ import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { ClineMessageModelInfo } from "./messages"
import type { ModelsDevProviderModels } from "./models-dev"
import { OnboardingModelGroup } from "./proto/cline/state"
import { Mode } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
@@ -41,7 +40,6 @@ export interface ExtensionState {
isNewUser: boolean
welcomeViewCompleted: boolean
onboardingModels: OnboardingModelGroup | undefined
modelsDevProviderModels?: ModelsDevProviderModels
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
-124
View File
@@ -1,124 +0,0 @@
import { expect } from "chai"
import { afterEach, describe, it } from "mocha"
import { anthropicModels, type ModelInfo } from "./api"
import {
applyModelsDevProviderModels,
type ModelsDevPayload,
mergeModelsDevModels,
normalizeModelsDevProviderModels,
} from "./models-dev"
describe("models.dev static provider augmentation", () => {
const augmentedAnthropicModelId = "claude-test-model-from-models-dev"
afterEach(() => {
delete (anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]
})
it("normalizes supported models.dev models and filters unsupported entries", () => {
const payload: ModelsDevPayload = {
anthropic: {
models: {
[augmentedAnthropicModelId]: {
name: "Claude Test",
tool_call: true,
reasoning: true,
reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
release_date: "2026-01-01",
limit: {
context: 200_000,
output: 64_000,
},
cost: {
input: 3,
output: 15,
cache_read: 0.3,
cache_write: 3.75,
},
modalities: {
input: ["text", "image"],
},
},
"claude-deprecated": {
tool_call: true,
status: "deprecated",
},
"claude-no-tools": {
tool_call: false,
},
},
},
}
const providerModels = normalizeModelsDevProviderModels(payload)
const model = providerModels.anthropic?.[augmentedAnthropicModelId]
expect(model).to.not.equal(undefined)
expect(model?.name).to.equal("Claude Test")
expect(model?.contextWindow).to.equal(200_000)
expect(model?.maxTokens).to.equal(64_000)
expect(model?.supportsImages).to.equal(true)
expect(model?.supportsPromptCache).to.equal(true)
expect(model?.supportsReasoning).to.equal(true)
expect(model?.supportsReasoningEffort).to.equal(true)
expect(model?.inputPrice).to.equal(3)
expect(providerModels.anthropic?.["claude-deprecated"]).to.equal(undefined)
expect(providerModels.anthropic?.["claude-no-tools"]).to.equal(undefined)
})
it("keeps hardcoded model info while appending missing models.dev ids", () => {
const staticModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 1,
contextWindow: 1,
supportsPromptCache: false,
inputPrice: 1,
outputPrice: 1,
},
}
const modelsDevModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 2,
contextWindow: 2,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 2,
},
added: {
maxTokens: 3,
contextWindow: 3,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 3,
},
}
const merged = mergeModelsDevModels(staticModels, modelsDevModels)
expect(merged.existing.maxTokens).to.equal(1)
expect(merged.added.maxTokens).to.equal(3)
expect(Object.keys(merged)).to.deep.equal(["existing", "added"])
})
it("applies missing models.dev ids to existing static provider maps", () => {
const modelInfo: ModelInfo = {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3,
outputPrice: 15,
cacheReadsPrice: 0.3,
cacheWritesPrice: 3.75,
}
applyModelsDevProviderModels({
anthropic: {
[augmentedAnthropicModelId]: modelInfo,
},
})
expect((anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]).to.deep.equal(modelInfo)
})
})
-236
View File
@@ -1,236 +0,0 @@
import {
type ApiProvider,
anthropicModels,
bedrockModels,
cerebrasModels,
deepSeekModels,
fireworksModels,
geminiModels,
huggingFaceModels,
internationalZAiModels,
type ModelInfo,
mainlandZAiModels,
minimaxModels,
mistralModels,
moonshotModels,
nebiusModels,
nousResearchModels,
openAiNativeModels,
sambanovaModels,
vertexModels,
wandbModels,
xaiModels,
} from "./api"
export type ModelsDevModelInfo = ModelInfo & {
releaseDate?: string
family?: string
supportsReasoningEffort?: boolean
supportsTools?: boolean
}
export type ModelsDevProviderModels = Partial<Record<ApiProvider, Record<string, ModelsDevModelInfo>>>
export interface ModelsDevModel {
name?: string
tool_call?: boolean
reasoning?: boolean
structured_output?: boolean
temperature?: boolean
reasoning_options?: {
type?: string
values?: string[]
min?: number
}[]
release_date?: string
family?: string
limit?: {
context?: number
input?: number
output?: number
}
cost?: {
input?: number
output?: number
cache_read?: number
cache_write?: number
}
modalities?: {
input?: string[]
}
status?: string
}
export type ModelsDevPayload = Record<string, { models?: Record<string, ModelsDevModel> }>
const DEFAULT_MAX_TOKENS = 4096
const MODELS_DEV_PROVIDER_KEY_MAP: Record<string, ApiProvider> = {
"amazon-bedrock": "bedrock",
anthropic: "anthropic",
cerebras: "cerebras",
deepseek: "deepseek",
"fireworks-ai": "fireworks",
google: "gemini",
"google-vertex": "vertex",
huggingface: "huggingface",
minimax: "minimax",
mistral: "mistral",
moonshotai: "moonshot",
nebius: "nebius",
"nous-research": "nousResearch",
openai: "openai-native",
sambanova: "sambanova",
wandb: "wandb",
xai: "xai",
zai: "zai",
}
const STATIC_MODELS_BY_PROVIDER: Partial<Record<ApiProvider, Record<string, ModelInfo>>> = {
anthropic: anthropicModels as Record<string, ModelInfo>,
bedrock: bedrockModels as Record<string, ModelInfo>,
cerebras: cerebrasModels as Record<string, ModelInfo>,
deepseek: deepSeekModels as Record<string, ModelInfo>,
fireworks: fireworksModels as Record<string, ModelInfo>,
gemini: geminiModels as Record<string, ModelInfo>,
huggingface: huggingFaceModels as Record<string, ModelInfo>,
minimax: minimaxModels as Record<string, ModelInfo>,
mistral: mistralModels as Record<string, ModelInfo>,
moonshot: moonshotModels as Record<string, ModelInfo>,
nebius: nebiusModels as Record<string, ModelInfo>,
nousResearch: nousResearchModels as Record<string, ModelInfo>,
"openai-native": openAiNativeModels as Record<string, ModelInfo>,
sambanova: sambanovaModels as Record<string, ModelInfo>,
vertex: vertexModels as Record<string, ModelInfo>,
wandb: wandbModels as Record<string, ModelInfo>,
xai: xaiModels as Record<string, ModelInfo>,
zai: internationalZAiModels as Record<string, ModelInfo>,
}
function parseReleaseDate(value: string | undefined): number {
if (!value) {
return Number.NEGATIVE_INFINITY
}
const timestamp = Date.parse(value)
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
}
function sortModelsByReleaseDate(models: Record<string, ModelsDevModelInfo>): Record<string, ModelsDevModelInfo> {
return Object.fromEntries(
Object.entries(models).sort(([modelIdA, modelA], [modelIdB, modelB]) => {
const releaseDateA = parseReleaseDate(modelA.releaseDate)
const releaseDateB = parseReleaseDate(modelB.releaseDate)
if (releaseDateA !== releaseDateB) {
return releaseDateB - releaseDateA
}
return modelIdA.localeCompare(modelIdB)
}),
)
}
function hasCachePricing(cost: ModelsDevModel["cost"]): boolean {
return typeof cost?.cache_read === "number" || typeof cost?.cache_write === "number"
}
function supportsReasoningEffort(model: ModelsDevModel): boolean {
return model.reasoning_options?.some((option) => option.type === "effort") ?? false
}
function toModelInfo(modelId: string, model: ModelsDevModel): ModelsDevModelInfo {
const supportsPromptCache = hasCachePricing(model.cost)
const supportsReasoning = model.reasoning === true
const supportsEffort = supportsReasoningEffort(model)
const info: ModelsDevModelInfo = {
name: model.name || modelId,
maxTokens: Math.floor(model.limit?.output ?? DEFAULT_MAX_TOKENS),
contextWindow: model.limit?.context,
supportsImages: model.modalities?.input?.includes("image") ?? false,
supportsPromptCache,
supportsReasoning,
inputPrice: model.cost?.input ?? 0,
outputPrice: model.cost?.output ?? 0,
cacheReadsPrice: model.cost?.cache_read,
cacheWritesPrice: model.cost?.cache_write,
description: "",
thinkingConfig: supportsReasoning ? { maxBudget: model.limit?.output ?? DEFAULT_MAX_TOKENS } : undefined,
releaseDate: model.release_date,
family: model.family,
supportsReasoningEffort: supportsEffort,
supportsTools: model.tool_call === true,
}
return info
}
function isSupportedModelsDevModel(model: ModelsDevModel): boolean {
return model.tool_call === true && model.status !== "deprecated"
}
export function normalizeModelsDevProviderModels(payload: ModelsDevPayload): ModelsDevProviderModels {
const providerModels: ModelsDevProviderModels = {}
for (const [modelsDevProviderKey, providerId] of Object.entries(MODELS_DEV_PROVIDER_KEY_MAP)) {
const sourceModels = payload[modelsDevProviderKey]?.models
if (!sourceModels) {
continue
}
const models: Record<string, ModelsDevModelInfo> = {}
for (const [modelId, model] of Object.entries(sourceModels)) {
if (!isSupportedModelsDevModel(model)) {
continue
}
models[modelId] = toModelInfo(modelId, model)
}
if (Object.keys(models).length > 0) {
providerModels[providerId] = sortModelsByReleaseDate(models)
}
}
return providerModels
}
export function getStaticModelsForModelsDevProvider(providerId: ApiProvider): Record<string, ModelInfo> | undefined {
return STATIC_MODELS_BY_PROVIDER[providerId]
}
export function mergeModelsDevModels(
staticModels: Record<string, ModelInfo>,
modelsDevModels: Record<string, ModelInfo> | undefined,
): Record<string, ModelInfo> {
if (!modelsDevModels || Object.keys(modelsDevModels).length === 0) {
return staticModels
}
const additions = Object.fromEntries(Object.entries(modelsDevModels).filter(([modelId]) => !(modelId in staticModels)))
return {
...staticModels,
...additions,
}
}
export function applyModelsDevProviderModels(providerModels: ModelsDevProviderModels | undefined): void {
if (!providerModels) {
return
}
for (const [providerId, models] of Object.entries(providerModels) as [ApiProvider, Record<string, ModelInfo>][]) {
const staticModels = getStaticModelsForModelsDevProvider(providerId)
if (!staticModels) {
continue
}
for (const [modelId, modelInfo] of Object.entries(models)) {
if (!(modelId in staticModels)) {
staticModels[modelId] = modelInfo
}
if (providerId === "zai") {
const mainlandModels = mainlandZAiModels as Record<string, ModelInfo>
if (!(modelId in mainlandModels)) {
mainlandModels[modelId] = modelInfo
}
}
}
}
}
@@ -4,7 +4,6 @@ import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
import { applyModelsDevProviderModels } from "@shared/models-dev"
import type { UserInfo } from "@shared/proto/cline/account"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
@@ -360,7 +359,6 @@ export const ExtensionStateContextProvider: React.FC<{
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
applyModelsDevProviderModels(stateData.modelsDevProviderModels)
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
@@ -399,6 +399,47 @@ describe("default search_codebase tool", () => {
},
]);
});
it("accepts object input with queries as a JSON-encoded string array", async () => {
const execute = vi.fn(async (query: string) => `results:${query}`);
const tool = createSearchTool(execute);
const result = await tool.execute(
{
queries: JSON.stringify(["createSearchTool", "run_commands"]),
} as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "createSearchTool",
result: "results:createSearchTool",
success: true,
},
{
query: "run_commands",
result: "results:run_commands",
success: true,
},
]);
expect(execute).toHaveBeenNthCalledWith(
1,
"createSearchTool",
process.cwd(),
expect.objectContaining({ iteration: 1 }),
);
expect(execute).toHaveBeenNthCalledWith(
2,
"run_commands",
process.cwd(),
expect.objectContaining({ iteration: 1 }),
);
});
});
describe("default apply_patch tool", () => {
@@ -535,6 +576,111 @@ describe("default run_commands tool", () => {
);
});
it("accepts object input with commands as a JSON-encoded string array", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createBashTool(execute);
const command = "cd /repo && bunx tsc --noEmit --pretty 2>&1 | head -40";
const result = await tool.execute(
{ commands: JSON.stringify([command]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: command,
result: `ran:${command}`,
success: true,
},
]);
expect(execute).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
command,
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts JSON-encoded command arrays in the Windows shell tool", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createWindowsShellTool(execute);
const result = await tool.execute(
{ commands: JSON.stringify(["git status --short"]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "git status --short",
result: "ran:git status --short",
success: true,
},
]);
expect(execute).toHaveBeenCalledWith(
"git status --short",
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts nested JSON-encoded commands payloads", async () => {
const execute = vi.fn(async (command: string | { command: string }) =>
typeof command === "string" ? `ran:${command}` : `ran:${command.command}`,
);
const tool = createBashTool(execute);
const result = await tool.execute(
{
commands: JSON.stringify({
commands: JSON.stringify(["git status --short"]),
}),
} as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "git status --short",
result: "ran:git status --short",
success: true,
},
]);
expect(execute).toHaveBeenCalledWith(
"git status --short",
process.cwd(),
expect.objectContaining({
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
}),
);
});
it("accepts common single-command aliases", async () => {
const execute = vi.fn(
async (command: string | { command: string }) =>
@@ -1360,6 +1506,45 @@ describe("default read_files tool", () => {
);
});
it("accepts object input with files as a JSON-encoded string array", async () => {
const execute = vi.fn(
async (request: { path: string }) => `content:${request.path}`,
);
const tool = createReadFilesTool(execute);
const result = await tool.execute(
{ files: JSON.stringify(["/tmp/a.ts", "/tmp/b.ts"]) } as never,
{
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
},
);
expect(result).toEqual([
{
query: "/tmp/a.ts",
result: "content:/tmp/a.ts",
success: true,
},
{
query: "/tmp/b.ts",
result: "content:/tmp/b.ts",
success: true,
},
]);
expect(execute).toHaveBeenNthCalledWith(
1,
{ path: "/tmp/a.ts" },
expect.objectContaining({ iteration: 1 }),
);
expect(execute).toHaveBeenNthCalledWith(
2,
{ path: "/tmp/b.ts" },
expect.objectContaining({ iteration: 1 }),
);
});
it("rejects invalid union inputs before calling the executor", async () => {
const execute = vi.fn(async () => "should not run");
const tool = createReadFilesTool(execute);
@@ -26,6 +26,9 @@ import {
formatRunCommandQueryPreview,
getEditorSizeError,
getReadFileRangeError,
normalizeJsonLikeReadFilesInput,
normalizeJsonLikeRunCommandsInput,
normalizeJsonLikeSearchCodebaseInput,
normalizeRunCommandsInput,
TimeoutError,
withTimeout,
@@ -118,10 +121,21 @@ function getHeredocDelimiter(command: string): string | undefined {
return match?.[1] ?? match?.[2] ?? match?.[3];
}
function coalesceSplitHeredocCommands(commands: string[]): string[] {
const coalesced: string[] = [];
function coalesceSplitHeredocCommands(commands: string[]): string[];
function coalesceSplitHeredocCommands(
commands: Array<string | StructuredCommandInput>,
): Array<string | StructuredCommandInput>;
function coalesceSplitHeredocCommands(
commands: Array<string | StructuredCommandInput>,
): Array<string | StructuredCommandInput> {
const coalesced: Array<string | StructuredCommandInput> = [];
for (let index = 0; index < commands.length; index += 1) {
const command = commands[index];
if (typeof command !== "string") {
coalesced.push(command);
continue;
}
const delimiter = getHeredocDelimiter(command);
if (!delimiter) {
coalesced.push(command);
@@ -130,7 +144,9 @@ function coalesceSplitHeredocCommands(commands: string[]): string[] {
const endIndex = commands.findIndex(
(nextCommand, nextIndex) =>
nextIndex > index && nextCommand.trim() === delimiter,
nextIndex > index &&
typeof nextCommand === "string" &&
nextCommand.trim() === delimiter,
);
if (endIndex === -1) {
coalesced.push(command);
@@ -141,7 +157,9 @@ function coalesceSplitHeredocCommands(commands: string[]): string[] {
while (index < endIndex) {
index += 1;
const nextCommand = commands[index];
parts.push(nextCommand);
if (typeof nextCommand === "string") {
parts.push(nextCommand);
}
}
coalesced.push(parts.join("\n"));
}
@@ -176,7 +194,10 @@ export function createReadFilesTool(
retryable: true,
maxRetries: 1,
execute: async (input, context) => {
const validate = validateWithZod(ReadFilesInputUnionSchema, input);
const validate = validateWithZod(
ReadFilesInputUnionSchema,
normalizeJsonLikeReadFilesInput(input),
);
let requests: ReadFileRequest[];
if (typeof validate === "string") {
requests = [{ path: validate }];
@@ -270,7 +291,10 @@ export function createSearchTool(
maxRetries: 1,
execute: async (input, context) => {
// Validate input with Zod schema
const validate = validateWithZod(SearchCodebaseUnionInputSchema, input);
const validate = validateWithZod(
SearchCodebaseUnionInputSchema,
normalizeJsonLikeSearchCodebaseInput(input),
);
const queries = Array.isArray(validate)
? validate
: typeof validate === "object"
@@ -336,7 +360,10 @@ export function createBashTool(
retryable: false, // Shell commands often have side effects
maxRetries: 0,
execute: async (input, context) => {
const validate = validateWithZod(RunCommandsInputUnionSchema, input);
const validate = validateWithZod(
RunCommandsInputUnionSchema,
normalizeJsonLikeRunCommandsInput(input),
);
let commands: string[];
if (typeof validate === "string") {
commands = [validate];
@@ -77,10 +77,72 @@ export function getReadFileRangeError(request: ReadFileRequest): string | null {
return `start_line must be less than or equal to end_line (received start_line: ${start_line}, end_line: ${end_line})`;
}
function parseJsonLikeString(input: unknown): unknown {
if (typeof input !== "string") {
return input;
}
const trimmed = input.trim();
if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) {
return input;
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
return input;
}
}
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input != null && !Array.isArray(input);
}
export function normalizeJsonLikeToolInput(
input: unknown,
keys: string[],
): unknown {
const parsed = parseJsonLikeString(input);
if (!isRecord(parsed)) {
return parsed;
}
const normalized = { ...parsed };
for (const key of keys) {
if (!(key in normalized)) {
continue;
}
const value = parseJsonLikeString(normalized[key]);
if (isRecord(value) && key in value) {
normalized[key] = parseJsonLikeString(value[key]);
} else {
normalized[key] = value;
}
}
return normalized;
}
export function normalizeJsonLikeRunCommandsInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["commands"]);
}
export function normalizeJsonLikeReadFilesInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["files", "file_paths", "paths"]);
}
export function normalizeJsonLikeSearchCodebaseInput(input: unknown): unknown {
return normalizeJsonLikeToolInput(input, ["queries"]);
}
export function normalizeRunCommandsInput(
input: unknown,
): Array<string | StructuredCommandInput> {
const validate = validateWithZod(StructuredCommandsInputUnionSchema, input);
const validate = validateWithZod(
StructuredCommandsInputUnionSchema,
normalizeJsonLikeRunCommandsInput(input),
);
if (typeof validate === "string") {
return [validate];
@@ -155,15 +155,11 @@ export const StructuredCommandsInputSchema = z.object({
* Union schema for run_commands tool input. More flexible.
*/
export const StructuredCommandsInputUnionSchema = z.union([
RunCommandsInputSchema,
StructuredCommandsInputSchema,
z.object({ commands: StructuredCommandEntrySchema }),
z.array(StructuredCommandInputSchema),
StructuredCommandInputSchema,
z.object({ command: CommandInputSchema }),
z.object({ cmd: CommandInputSchema }),
z.array(z.string()),
z.string(),
RunCommandsInputUnionSchema,
]);
/**