Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,42 @@ describe("ProviderActivityProjection", () => {
expect(payload?.data?.item?.result?.startsWith("...")).toBe(false);
});

it("preserves base64 image blocks on Claude tool results", () => {
const imageData = `iVBORw0KGgo${"A".repeat(MAX_THREAD_ACTIVITY_PAYLOAD_TEXT_LENGTH + 128)}`;
const activities = projectRuntimeEventToActivities({
type: "item.completed",
eventId: EventId.make("evt-read-image-completed"),
provider: ProviderDriverKind.make("claude"),
threadId: ThreadId.make("thread-1"),
turnId: TurnId.make("turn-1"),
createdAt: "2026-06-01T12:00:00.000Z",
payload: {
itemType: "image_view",
title: "Read screenshot.png",
data: {
toolName: "Read",
input: { file_path: "C:\\shots\\screenshot.png" },
result: {
type: "tool_result",
tool_use_id: "tool-read-1",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: imageData },
},
],
},
},
},
} satisfies ProviderRuntimeEvent);

const payload = activities[0]?.payload as
| { data?: { result?: { content?: Array<{ source?: { data?: string } }> } } }
| undefined;

expect(payload?.data?.result?.content?.[0]?.source?.data).toBe(imageData);
});

it("still compacts non-image result payload strings", () => {
const largeResult = Array.from({ length: 1_000 }, (_, index) => `line ${index}`).join("\n");
const activities = projectRuntimeEventToActivities({
Expand Down
27 changes: 22 additions & 5 deletions apps/server/src/orchestration/Layers/ProviderActivityProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ function isImageGenerationPayloadRecord(value: Record<string, unknown>): boolean
);
}

/** A Claude `tool_result` image block's `source`: `{type: "base64", media_type:
* "image/...", data}`. The data only works whole -- a trimmed copy draws as a
* broken image -- so it keeps its full length. */
function isBase64ImageSourceRecord(value: Record<string, unknown>): boolean {
return (
value.type === "base64" &&
typeof value.media_type === "string" &&
value.media_type.toLowerCase().startsWith("image/") &&
typeof value.data === "string"
);
}

function compactActivityPayloadData(
value: unknown,
key?: string | undefined,
Expand Down Expand Up @@ -147,12 +159,17 @@ function compactActivityPayloadData(

const entries = Object.entries(value as Record<string, unknown>);
const compacted: Record<string, unknown> = {};
const preserveImageResult = isImageGenerationPayloadRecord(value as Record<string, unknown>);
const record = value as Record<string, unknown>;
const preserveImageResult = isImageGenerationPayloadRecord(record);
const preserveImageData = isBase64ImageSourceRecord(record);
for (const [entryKey, entryValue] of entries.slice(0, MAX_THREAD_ACTIVITY_PAYLOAD_OBJECT_KEYS)) {
compacted[entryKey] =
preserveImageResult && entryKey === "result" && typeof entryValue === "string"
? entryValue
: compactActivityPayloadData(entryValue, entryKey, depth + 1, limits);
const preserved =
typeof entryValue === "string" &&
((preserveImageResult && entryKey === "result") ||
(preserveImageData && entryKey === "data"));
compacted[entryKey] = preserved
? entryValue
: compactActivityPayloadData(entryValue, entryKey, depth + 1, limits);
}
if (entries.length > MAX_THREAD_ACTIVITY_PAYLOAD_OBJECT_KEYS) {
compacted.__truncatedKeys = entries.length - MAX_THREAD_ACTIVITY_PAYLOAD_OBJECT_KEYS;
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2843,6 +2843,48 @@ describe("deriveWorkLogEntries", () => {
expect(entry?.images?.[0]?.previewUrl).toBeUndefined();
});

it("falls back to the file path when a stored image block was trimmed", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
id: "claude-read-trimmed",
kind: "tool.completed",
summary: "Read icon.png",
payload: {
itemType: "image_view",
title: "Read icon.png",
data: {
toolName: "Read",
input: { file_path: "C:\\Users\\wilfr\\AppData\\Local\\Temp\\icon.png" },
result: {
type: "tool_result",
tool_use_id: "toolu_read",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "...uLW518pMPMrvYRRZoMTJG",
},
},
],
},
item: { id: "toolu_read" },
},
},
}),
];

const [entry] = deriveWorkLogEntries(activities);
expect(entry?.images).toEqual([
{
id: "toolu_read",
name: "icon.png",
path: "C:\\Users\\wilfr\\AppData\\Local\\Temp\\icon.png",
},
]);
});

it("previews the image blocks a screenshot tool returned inline", () => {
const screenshotBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3904,7 +3904,14 @@ function imagePathFromPayload(payload: Record<string, unknown> | null): unknown
* screenshot tool's `{type: "image", source: {type: "base64", ...}}` content
* blocks are already here; reading them is what makes an MCP screenshot row a
* picture without the event log holding a second copy.
*
* Rows saved before the server learned to keep image bytes whole hold a
* trimmed copy (`...` plus the tail). That is not base64, and a data URL built
* from it draws as a broken image, so such a block is skipped and the row falls
* back to loading the named path.
*/
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;

function imageBlocksFromPayload(
payload: Record<string, unknown> | null,
): Array<{ mimeType: string; base64: string }> {
Expand All @@ -3922,7 +3929,7 @@ function imageBlocksFromPayload(
}
const mimeType = asTrimmedString(source.media_type);
const base64 = asTrimmedString(source.data);
return mimeType && base64 ? [{ mimeType, base64 }] : [];
return mimeType && base64 && BASE64_PATTERN.test(base64) ? [{ mimeType, base64 }] : [];
});
}

Expand Down
Loading