fix: Handle long Claude code messages (#5072)

This commit is contained in:
Daniel 2025-06-24 10:34:06 -05:00 committed by GitHub
parent 64901c867c
commit 954825afb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1006 additions and 352 deletions

View file

@ -5,9 +5,29 @@ import { anthropicModels } from "./anthropic.js"
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
export const claudeCodeModels = {
"claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"],
"claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"],
"claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"],
"claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
"claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"],
"claude-sonnet-4-20250514": {
...anthropicModels["claude-sonnet-4-20250514"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-20250514": {
...anthropicModels["claude-opus-4-20250514"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-7-sonnet-20250219": {
...anthropicModels["claude-3-7-sonnet-20250219"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-5-sonnet-20241022": {
...anthropicModels["claude-3-5-sonnet-20241022"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-5-haiku-20241022": {
...anthropicModels["claude-3-5-haiku-20241022"],
supportsImages: false,
supportsPromptCache: false,
},
} as const satisfies Record<string, ModelInfo>

View file

@ -16,6 +16,15 @@ vi.mock("vscode", () => ({
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
workspace: {
workspaceFolders: [
{
uri: {
fsPath: "/mock/workspace",
},
},
],
},
}))
vi.mock("../../core/webview/ClineProvider")

View file

@ -1,230 +1,503 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
import { ClaudeCodeHandler } from "../claude-code"
import { ApiHandlerOptions } from "../../../shared/api"
import { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
// Mock the runClaudeCode function
vi.mock("../../../integrations/claude-code/run", () => ({
runClaudeCode: vi.fn(),
}))
// Mock the message filter
vi.mock("../../../integrations/claude-code/message-filter", () => ({
filterMessagesForClaudeCode: vi.fn((messages) => messages),
}))
const { runClaudeCode } = await import("../../../integrations/claude-code/run")
const { filterMessagesForClaudeCode } = await import("../../../integrations/claude-code/message-filter")
const mockRunClaudeCode = vi.mocked(runClaudeCode)
// Mock the EventEmitter for the process
class MockEventEmitter {
private handlers: { [event: string]: ((...args: any[]) => void)[] } = {}
on(event: string, handler: (...args: any[]) => void) {
if (!this.handlers[event]) {
this.handlers[event] = []
}
this.handlers[event].push(handler)
}
emit(event: string, ...args: any[]) {
if (this.handlers[event]) {
this.handlers[event].forEach((handler) => handler(...args))
}
}
}
const mockFilterMessages = vi.mocked(filterMessagesForClaudeCode)
describe("ClaudeCodeHandler", () => {
let handler: ClaudeCodeHandler
let mockProcess: any
beforeEach(() => {
vi.clearAllMocks()
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "claude-3-5-sonnet-20241022",
}
handler = new ClaudeCodeHandler(options)
})
const mainEmitter = new MockEventEmitter()
mockProcess = {
stdout: new MockEventEmitter(),
stderr: new MockEventEmitter(),
on: mainEmitter.on.bind(mainEmitter),
emit: mainEmitter.emit.bind(mainEmitter),
test("should create handler with correct model configuration", () => {
const model = handler.getModel()
expect(model.id).toBe("claude-3-5-sonnet-20241022")
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(false)
})
test("should use default model when invalid model provided", () => {
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "invalid-model",
}
const handlerWithInvalidModel = new ClaudeCodeHandler(options)
const model = handlerWithInvalidModel.getModel()
mockRunClaudeCode.mockReturnValue(mockProcess)
expect(model.id).toBe("claude-sonnet-4-20250514") // default model
})
test("should filter messages and call runClaudeCode", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }]
mockFilterMessages.mockReturnValue(filteredMessages)
// Mock empty async generator
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
// Empty generator for basic test
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
// Need to start iterating to trigger the call
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// Verify message filtering was called
expect(mockFilterMessages).toHaveBeenCalledWith(messages)
// Verify runClaudeCode was called with filtered messages
expect(mockRunClaudeCode).toHaveBeenCalledWith({
systemPrompt,
messages: filteredMessages,
path: "claude",
modelId: "claude-3-5-sonnet-20241022",
})
})
test("should handle thinking content properly", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Start the stream
const stream = handler.createMessage(systemPrompt, messages)
const streamGenerator = stream[Symbol.asyncIterator]()
// Simulate thinking content response
const thinkingResponse = {
type: "assistant",
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "I need to think about this carefully...",
signature: "abc123",
// Mock async generator that yields thinking content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "I need to think about this carefully...",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
service_tier: "standard" as const,
},
},
session_id: "session_123",
} as any,
session_id: "session_123",
}
}
// Emit the thinking response and wait for processing
setImmediate(() => {
mockProcess.stdout.emit("data", JSON.stringify(thinkingResponse) + "\n")
setImmediate(() => {
mockProcess.emit("close", 0)
})
})
mockRunClaudeCode.mockReturnValue(mockGenerator())
// Get the result
const result = await streamGenerator.next()
const stream = handler.createMessage(systemPrompt, messages)
const results = []
expect(result.done).toBe(false)
expect(result.value).toEqual({
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "reasoning",
text: "I need to think about this carefully...",
})
})
test("should handle redacted thinking content", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields redacted thinking content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "redacted_thinking",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "reasoning",
text: "[Redacted thinking block]",
})
})
test("should handle mixed content types", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const stream = handler.createMessage(systemPrompt, messages)
const streamGenerator = stream[Symbol.asyncIterator]()
// Simulate mixed content response
const mixedResponse = {
type: "assistant",
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "Let me think about this...",
// Mock async generator that yields mixed content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "Let me think about this...",
},
{
type: "text",
text: "Here's my response!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
{
type: "text",
text: "Here's my response!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
service_tier: "standard" as const,
},
},
session_id: "session_123",
} as any,
session_id: "session_123",
}
}
// Emit the mixed response and wait for processing
setImmediate(() => {
mockProcess.stdout.emit("data", JSON.stringify(mixedResponse) + "\n")
setImmediate(() => {
mockProcess.emit("close", 0)
})
})
mockRunClaudeCode.mockReturnValue(mockGenerator())
// Get the first result (thinking)
const thinkingResult = await streamGenerator.next()
expect(thinkingResult.done).toBe(false)
expect(thinkingResult.value).toEqual({
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "reasoning",
text: "Let me think about this...",
})
// Get the second result (text)
const textResult = await streamGenerator.next()
expect(textResult.done).toBe(false)
expect(textResult.value).toEqual({
expect(results[1]).toEqual({
type: "text",
text: "Here's my response!",
})
})
test("should handle stop_reason with thinking content in error messages", async () => {
test("should handle string chunks from generator", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const stream = handler.createMessage(systemPrompt, messages)
const streamGenerator = stream[Symbol.asyncIterator]()
// Simulate error response with thinking content
const errorResponse = {
type: "assistant",
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "This is an error scenario",
},
],
stop_reason: "max_tokens",
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
service_tier: "standard" as const,
},
},
session_id: "session_123",
// Mock async generator that yields string chunks
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield "This is a string chunk"
yield "Another string chunk"
}
// Emit the error response and wait for processing
setImmediate(() => {
mockProcess.stdout.emit("data", JSON.stringify(errorResponse) + "\n")
setImmediate(() => {
mockProcess.emit("close", 0)
})
})
mockRunClaudeCode.mockReturnValue(mockGenerator())
// Should throw error with thinking content
await expect(streamGenerator.next()).rejects.toThrow("This is an error scenario")
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "This is a string chunk",
})
expect(results[1]).toEqual({
type: "text",
text: "Another string chunk",
})
})
test("should handle incomplete JSON in buffer on process close", async () => {
test("should handle usage and cost tracking with paid usage", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator with init, assistant, and result messages
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
// Init message indicating paid usage
yield {
type: "system" as const,
subtype: "init" as const,
session_id: "session_123",
tools: [],
mcp_servers: [],
apiKeySource: "/login managed key",
}
// Assistant message
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "Hello there!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 3,
},
} as any,
session_id: "session_123",
}
// Result message
yield {
type: "result" as const,
subtype: "success" as const,
total_cost_usd: 0.05,
is_error: false,
duration_ms: 1000,
duration_api_ms: 800,
num_turns: 1,
result: "success",
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const streamGenerator = stream[Symbol.asyncIterator]()
const results = []
// Simulate incomplete JSON data followed by process close
setImmediate(() => {
// Send incomplete JSON (missing closing brace)
mockProcess.stdout.emit("data", '{"type":"assistant","message":{"id":"msg_123"')
setImmediate(() => {
mockProcess.emit("close", 0)
})
for await (const chunk of stream) {
results.push(chunk)
}
// Should have text chunk and usage chunk
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "Hello there!",
})
expect(results[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
cacheWriteTokens: 3,
totalCost: 0.05, // Paid usage, so cost is included
})
})
// Should complete without throwing, incomplete JSON should be discarded
const result = await streamGenerator.next()
expect(result.done).toBe(true)
test("should handle usage tracking with subscription (free) usage", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator with subscription usage
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
// Init message indicating subscription usage
yield {
type: "system" as const,
subtype: "init" as const,
session_id: "session_123",
tools: [],
mcp_servers: [],
apiKeySource: "none", // Subscription usage
}
// Assistant message
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "Hello there!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
// Result message
yield {
type: "result" as const,
subtype: "success" as const,
total_cost_usd: 0.05,
is_error: false,
duration_ms: 1000,
duration_api_ms: 800,
num_turns: 1,
result: "success",
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
// Should have text chunk and usage chunk
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "Hello there!",
})
expect(results[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0, // Subscription usage, so cost is 0
})
})
test("should handle API errors properly", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields an API error
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: 'API Error: 400 {"error":{"message":"Invalid model name"}}',
},
],
stop_reason: "stop_sequence",
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const iterator = stream[Symbol.asyncIterator]()
// Should throw an error
await expect(iterator.next()).rejects.toThrow()
})
test("should log warning for unsupported tool_use content", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
// Mock async generator that yields tool_use content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "tool_use",
id: "tool_123",
name: "test_tool",
input: { test: "data" },
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
// Should log error for unsupported tool_use
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("tool_use is not supported yet"))
consoleSpy.mockRestore()
})
})

View file

@ -3,7 +3,7 @@ import { claudeCodeDefaultModelId, type ClaudeCodeModelId, claudeCodeModels } fr
import { type ApiHandler } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { runClaudeCode } from "../../integrations/claude-code/run"
import { ClaudeCodeMessage } from "../../integrations/claude-code/types"
import { filterMessagesForClaudeCode } from "../../integrations/claude-code/message-filter"
import { BaseProvider } from "./base-provider"
import { t } from "../../i18n"
import { ApiHandlerOptions } from "../../shared/api"
@ -17,61 +17,16 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
}
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Filter out image blocks since Claude Code doesn't support them
const filteredMessages = filterMessagesForClaudeCode(messages)
const claudeProcess = runClaudeCode({
systemPrompt,
messages,
messages: filteredMessages,
path: this.options.claudeCodePath,
modelId: this.getModel().id,
})
const dataQueue: string[] = []
let processError = null
let errorOutput = ""
let exitCode: number | null = null
let buffer = ""
claudeProcess.stdout.on("data", (data) => {
buffer += data.toString()
const lines = buffer.split("\n")
// Keep the last line in buffer as it might be incomplete
buffer = lines.pop() || ""
// Process complete lines
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine !== "") {
dataQueue.push(trimmedLine)
}
}
})
claudeProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
claudeProcess.on("close", (code) => {
exitCode = code
// Process any remaining data in buffer
const trimmedBuffer = buffer.trim()
if (trimmedBuffer) {
// Validate that the remaining buffer looks like valid JSON before processing
if (this.isLikelyValidJSON(trimmedBuffer)) {
dataQueue.push(trimmedBuffer)
} else {
console.warn(
"Discarding incomplete JSON data on process close:",
trimmedBuffer.substring(0, 100) + (trimmedBuffer.length > 100 ? "..." : ""),
)
}
buffer = ""
}
})
claudeProcess.on("error", (error) => {
processError = error
})
// Usage is included with assistant messages,
// but cost is included in the result chunk
let usage: ApiStreamUsageChunk = {
@ -82,72 +37,74 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
cacheWriteTokens: 0,
}
while (exitCode !== 0 || dataQueue.length > 0) {
if (dataQueue.length === 0) {
await new Promise((resolve) => setImmediate(resolve))
}
let isPaidUsage = true
if (exitCode !== null && exitCode !== 0) {
if (errorOutput) {
throw new Error(
t("common:errors.claudeCode.processExitedWithError", {
exitCode,
output: errorOutput.trim(),
}),
)
}
throw new Error(t("common:errors.claudeCode.processExited", { exitCode }))
}
const data = dataQueue.shift()
if (!data) {
continue
}
const chunk = this.attemptParseChunk(data)
if (!chunk) {
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
yield {
type: "text",
text: data || "",
text: chunk,
}
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = chunk.apiKeySource !== "none"
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
const firstContent = message.content[0]
const errorMessage =
this.getContentText(firstContent) ||
t("common:errors.claudeCode.stoppedWithReason", { reason: message.stop_reason })
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
if (errorMessage.includes("Invalid model name")) {
throw new Error(errorMessage + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`)
const isError = content && content.text.startsWith(`API Error`)
if (isError) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
const error = this.attemptParse(errorMessage)
if (!error) {
throw new Error(content.text)
}
if (error.error.message.includes("Invalid model name")) {
throw new Error(
content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`,
)
}
throw new Error(errorMessage)
}
throw new Error(errorMessage)
}
for (const content of message.content) {
if (content.type === "text") {
yield {
type: "text",
text: content.text,
}
} else if (content.type === "thinking") {
yield {
type: "reasoning",
text: content.thinking,
}
} else {
console.warn("Unsupported content type:", content)
switch (content.type) {
case "text":
yield {
type: "text",
text: content.text,
}
break
case "thinking":
yield {
type: "reasoning",
text: content.thinking || "",
}
break
case "redacted_thinking":
yield {
type: "reasoning",
text: "[Redacted thinking block]",
}
break
case "tool_use":
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
break
}
}
@ -161,16 +118,10 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
}
if (chunk.type === "result" && "result" in chunk) {
// Only use the cost from the CLI if provided
// Don't calculate cost as it may be $0 for subscription users
usage.totalCost = chunk.cost_usd ?? 0
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
}
if (processError) {
throw processError
}
}
}
@ -187,53 +138,10 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler {
}
}
private getContentText(content: any): string | undefined {
if (!content) return undefined
switch (content.type) {
case "text":
return content.text
case "thinking":
return content.thinking
default:
return undefined
}
}
private isLikelyValidJSON(data: string): boolean {
// Basic validation to check if the data looks like it could be valid JSON
const trimmed = data.trim()
if (!trimmed) return false
// Must start and end with appropriate JSON delimiters
const startsCorrectly = trimmed.startsWith("{") || trimmed.startsWith("[")
const endsCorrectly = trimmed.endsWith("}") || trimmed.endsWith("]")
if (!startsCorrectly || !endsCorrectly) return false
// Check for balanced braces/brackets (simple heuristic)
let braceCount = 0
let bracketCount = 0
for (const char of trimmed) {
if (char === "{") braceCount++
else if (char === "}") braceCount--
else if (char === "[") bracketCount++
else if (char === "]") bracketCount--
}
return braceCount === 0 && bracketCount === 0
}
// TODO: Validate instead of parsing
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
private attemptParse(str: string) {
try {
return JSON.parse(data)
} catch (error) {
console.error(
"Error parsing chunk:",
error,
"Data:",
data.substring(0, 100) + (data.length > 100 ? "..." : ""),
)
return JSON.parse(str)
} catch (err) {
return null
}
}

View file

@ -0,0 +1,263 @@
import { describe, test, expect } from "vitest"
import { filterMessagesForClaudeCode } from "../message-filter"
import type { Anthropic } from "@anthropic-ai/sdk"
describe("filterMessagesForClaudeCode", () => {
test("should pass through string messages unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello, this is a simple text message",
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
test("should pass through text-only content blocks unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "This is a text block",
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
test("should replace image blocks with text placeholders", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
},
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "text",
text: "[Image (base64): image/png not supported by Claude Code]",
},
],
},
])
})
test("should handle image blocks with unknown source types", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "image",
source: undefined as any,
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "[Image (unknown): unknown not supported by Claude Code]",
},
],
},
])
})
test("should handle mixed content with multiple images", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Compare these images:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data1",
},
},
{
type: "text",
text: "and",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/gif",
data: "base64data2",
},
},
{
type: "text",
text: "What do you think?",
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "Compare these images:",
},
{
type: "text",
text: "[Image (base64): image/jpeg not supported by Claude Code]",
},
{
type: "text",
text: "and",
},
{
type: "text",
text: "[Image (base64): image/gif not supported by Claude Code]",
},
{
type: "text",
text: "What do you think?",
},
],
},
])
})
test("should handle multiple messages with images", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "First message with text only",
},
{
role: "assistant",
content: [
{
type: "text",
text: "I can help with that.",
},
],
},
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "imagedata",
},
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: "First message with text only",
},
{
role: "assistant",
content: [
{
type: "text",
text: "I can help with that.",
},
],
},
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "text",
text: "[Image (base64): image/png not supported by Claude Code]",
},
],
},
])
})
test("should preserve other content block types unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Regular text",
},
// This would be some other content type that's not an image
{
type: "tool_use" as any,
id: "tool_123",
name: "test_tool",
input: { test: "data" },
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
})

View file

@ -0,0 +1,37 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
// Mock vscode workspace
vi.mock("vscode", () => ({
workspace: {
workspaceFolders: [
{
uri: {
fsPath: "/test/workspace",
},
},
],
},
}))
describe("runClaudeCode", () => {
beforeEach(() => {
vi.clearAllMocks()
})
test("should export runClaudeCode function", async () => {
const { runClaudeCode } = await import("../run")
expect(typeof runClaudeCode).toBe("function")
})
test("should be an async generator function", async () => {
const { runClaudeCode } = await import("../run")
const options = {
systemPrompt: "You are a helpful assistant",
messages: [{ role: "user" as const, content: "Hello" }],
}
const result = runClaudeCode(options)
expect(Symbol.asyncIterator in result).toBe(true)
expect(typeof result[Symbol.asyncIterator]).toBe("function")
})
})

View file

@ -0,0 +1,35 @@
import type { Anthropic } from "@anthropic-ai/sdk"
/**
* Filters out image blocks from messages since Claude Code doesn't support images.
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
*/
export function filterMessagesForClaudeCode(
messages: Anthropic.Messages.MessageParam[],
): Anthropic.Messages.MessageParam[] {
return messages.map((message) => {
// Handle simple string messages
if (typeof message.content === "string") {
return message
}
// Handle complex message structures
const filteredContent = message.content.map((block) => {
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
}
}
return block
})
return {
...message,
content: filteredContent,
}
})
}

View file

@ -1,21 +1,115 @@
import * as vscode from "vscode"
import Anthropic from "@anthropic-ai/sdk"
import type Anthropic from "@anthropic-ai/sdk"
import { execa } from "execa"
import { ClaudeCodeMessage } from "./types"
import readline from "readline"
export function runClaudeCode({
systemPrompt,
messages,
path,
modelId,
}: {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
type ClaudeCodeOptions = {
systemPrompt: string
messages: Anthropic.Messages.MessageParam[]
path?: string
modelId?: string
}) {
}
type ProcessState = {
partialData: string | null
error: Error | null
stderrLogs: string
exitCode: number | null
}
export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator<ClaudeCodeMessage | string> {
const process = runProcess(options)
const rl = readline.createInterface({
input: process.stdout,
})
try {
const processState: ProcessState = {
error: null,
stderrLogs: "",
exitCode: null,
partialData: null,
}
process.stderr.on("data", (data) => {
processState.stderrLogs += data.toString()
})
process.on("close", (code) => {
processState.exitCode = code
})
process.on("error", (err) => {
processState.error = err
})
for await (const line of rl) {
if (processState.error) {
throw processState.error
}
if (line.trim()) {
const chunk = parseChunk(line, processState)
if (!chunk) {
continue
}
yield chunk
}
}
// We rely on the assistant message. If the output was truncated, it's better having a poorly formatted message
// from which to extract something, than throwing an error/showing the model didn't return any messages.
if (processState.partialData && processState.partialData.startsWith(`{"type":"assistant"`)) {
yield processState.partialData
}
const { exitCode } = await process
if (exitCode !== null && exitCode !== 0) {
const errorOutput = processState.error?.message || processState.stderrLogs?.trim()
throw new Error(
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`,
)
}
} finally {
rl.close()
if (!process.killed) {
process.kill()
}
}
}
// We want the model to use our custom tool format instead of built-in tools.
// Disabling built-in tools prevents tool-only responses and ensures text output.
const claudeCodeTools = [
"Task",
"Bash",
"Glob",
"Grep",
"LS",
"exit_plan_mode",
"Read",
"Edit",
"MultiEdit",
"Write",
"NotebookRead",
"NotebookEdit",
"WebFetch",
"TodoRead",
"TodoWrite",
"WebSearch",
].join(",")
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions) {
const claudePath = path || "claude"
// TODO: Is it worth using sessions? Where do we store the session ID?
const args = [
"-p",
JSON.stringify(messages),
@ -24,7 +118,9 @@ export function runClaudeCode({
"--verbose",
"--output-format",
"stream-json",
// Cline will handle recursive calls
"--disallowedTools",
claudeCodeTools,
// Roo Code will handle recursive calls
"--max-turns",
"1",
]
@ -33,12 +129,49 @@ export function runClaudeCode({
args.push("--model", modelId)
}
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
return execa(claudePath, args, {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
env: {
...process.env,
// The default is 32000. However, I've gotten larger responses, so we increase it unless the user specified it.
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "64000",
},
cwd,
maxBuffer: 1024 * 1024 * 1000,
timeout: CLAUDE_CODE_TIMEOUT,
})
}
function parseChunk(data: string, processState: ProcessState) {
if (processState.partialData) {
processState.partialData += data
const chunk = attemptParseChunk(processState.partialData)
if (!chunk) {
return null
}
processState.partialData = null
return chunk
}
const chunk = attemptParseChunk(data)
if (!chunk) {
processState.partialData = data
}
return chunk
}
function attemptParseChunk(data: string): ClaudeCodeMessage | null {
try {
return JSON.parse(data)
} catch (error) {
console.error("Error parsing chunk:", error, data.length)
return null
}
}

View file

@ -1,40 +1,17 @@
import type { Anthropic } from "@anthropic-ai/sdk"
type InitMessage = {
type: "system"
subtype: "init"
session_id: string
tools: string[]
mcp_servers: string[]
apiKeySource: "none" | "/login managed key" | string
}
type ClaudeCodeContent =
| {
type: "text"
text: string
}
| {
type: "thinking"
thinking: string
signature?: string
}
type AssistantMessage = {
type: "assistant"
message: {
id: string
type: "message"
role: "assistant"
model: string
content: ClaudeCodeContent[]
stop_reason: string | null
stop_sequence: null
usage: {
input_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
output_tokens: number
service_tier: "standard"
}
}
message: Anthropic.Messages.Message
session_id: string
}
@ -45,13 +22,12 @@ type ErrorMessage = {
type ResultMessage = {
type: "result"
subtype: "success"
cost_usd: number
total_cost_usd: number
is_error: boolean
duration_ms: number
duration_api_ms: number
num_turns: number
result: string
total_cost: number
session_id: string
}