fix(claude-code-cli): forward image blocks in SDK query prompt (#4183)

This commit is contained in:
Jeremy 2026-04-14 09:30:02 -05:00
parent 759bed7dae
commit 01857ea180
2 changed files with 183 additions and 1 deletions

View file

@ -96,6 +96,31 @@ interface ParsedTextInputField {
secure: boolean;
}
interface SDKInputImageBlock {
type: "image";
source: {
type: "base64";
media_type: string;
data: string;
};
}
interface SDKInputTextBlock {
type: "text";
text: string;
}
type SDKInputUserContentBlock = SDKInputImageBlock | SDKInputTextBlock;
interface SDKInputUserMessage {
type: "user";
message: {
role: "user";
content: SDKInputUserContentBlock[];
};
parent_tool_use_id: null;
}
const OTHER_OPTION_LABEL = "None of the above";
const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i;
@ -222,6 +247,74 @@ export function buildPromptFromContext(context: Context): string {
return parts.join("\n\n");
}
function stripDataUriPrefix(value: string): string {
const commaIndex = value.indexOf(",");
if (value.startsWith("data:") && commaIndex !== -1) {
return value.slice(commaIndex + 1);
}
return value;
}
function inferMimeTypeFromDataUri(value: string): string | null {
const match = /^data:([^;,]+);base64,/.exec(value);
return match?.[1] ?? null;
}
export function extractImageBlocksFromContext(context: Context): SDKInputImageBlock[] {
const imageBlocks: SDKInputImageBlock[] = [];
for (const msg of context.messages) {
if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
for (const part of msg.content) {
if (!part || typeof part !== "object") continue;
const block = part as { type?: unknown; data?: unknown; mimeType?: unknown };
if (block.type !== "image" || typeof block.data !== "string") continue;
const mimeType =
typeof block.mimeType === "string" && block.mimeType.length > 0
? block.mimeType
: inferMimeTypeFromDataUri(block.data);
if (!mimeType) continue;
imageBlocks.push({
type: "image",
source: {
type: "base64",
media_type: mimeType,
data: stripDataUriPrefix(block.data),
},
});
}
}
return imageBlocks;
}
export function buildSdkQueryPrompt(
context: Context,
textPrompt: string = buildPromptFromContext(context),
): string | AsyncIterable<SDKInputUserMessage> {
const imageBlocks = extractImageBlocksFromContext(context);
if (imageBlocks.length === 0) {
return textPrompt;
}
const content: SDKInputUserContentBlock[] = [...imageBlocks];
if (textPrompt) {
content.push({ type: "text", text: textPrompt });
}
const sdkMessage: SDKInputUserMessage = {
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
};
return (async function* () {
yield sdkMessage;
})();
}
// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------
@ -821,6 +914,7 @@ async function pumpSdkMessages(
}
const prompt = buildPromptFromContext(context);
const queryPrompt = buildSdkQueryPrompt(context, prompt);
const permissionMode = await resolveClaudePermissionMode();
const sdkOpts = buildSdkOptions(
modelId,
@ -836,7 +930,7 @@ async function pumpSdkMessages(
);
const queryResult = sdk.query({
prompt,
prompt: queryPrompt,
options: {
...sdkOpts,
abortController: controller,

View file

@ -10,8 +10,10 @@ import {
mergePendingToolCalls,
resolveClaudePermissionMode,
buildPromptFromContext,
buildSdkQueryPrompt,
buildSdkOptions,
createClaudeCodeElicitationHandler,
extractImageBlocksFromContext,
extractToolResultsFromSdkUserMessage,
getClaudeLookupCommand,
parseAskUserQuestionsElicitation,
@ -167,6 +169,92 @@ describe("stream-adapter — full context prompt (#2859)", () => {
});
});
describe("stream-adapter — image prompt forwarding (#4183)", () => {
test("extractImageBlocksFromContext maps user image parts to Anthropic base64 image blocks", () => {
const context: Context = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "look" },
{
type: "image",
data: "data:image/png;base64,abc123",
mimeType: "image/png",
},
],
} as Message,
],
};
const imageBlocks = extractImageBlocksFromContext(context);
assert.deepEqual(imageBlocks, [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "abc123",
},
},
]);
});
test("buildSdkQueryPrompt returns plain string when no images exist in context", () => {
const context: Context = {
messages: [{ role: "user", content: "hello" } as Message],
};
const textPrompt = buildPromptFromContext(context);
const prompt = buildSdkQueryPrompt(context, textPrompt);
assert.equal(typeof prompt, "string");
assert.equal(prompt, textPrompt);
});
test("buildSdkQueryPrompt wraps images and prompt text in an SDK user message iterable", async () => {
const context: Context = {
messages: [
{
role: "user",
content: [
{ type: "image", data: "ZmFrZQ==", mimeType: "image/jpeg" },
{ type: "text", text: "What is in this image?" },
],
} as Message,
],
};
const textPrompt = buildPromptFromContext(context);
const prompt = buildSdkQueryPrompt(context, textPrompt);
assert.notEqual(typeof prompt, "string");
assert.ok(prompt && typeof (prompt as any)[Symbol.asyncIterator] === "function");
const messages: any[] = [];
for await (const item of prompt as AsyncIterable<any>) {
messages.push(item);
}
assert.equal(messages.length, 1);
assert.deepEqual(messages[0], {
type: "user",
message: {
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "ZmFrZQ==",
},
},
{ type: "text", text: textPrompt },
],
},
parent_tool_use_id: null,
});
});
});
// ---------------------------------------------------------------------------
// Bug #4102 — transcript fabrication regression tests
// ---------------------------------------------------------------------------