Skip to content

Commit c2a01e1

Browse files
authored
fix: tool and-interleaved (#1558)
* feat: preview tool call images * fix: preserve empty interleaved reasoning * fix: prevent raw image preview payloads * fix: scope empty reasoning to deepseek * fix: preserve image preview metadata and sanitize unsafe URLs * fix: normalize cached image previews
1 parent 7404160 commit c2a01e1

47 files changed

Lines changed: 1370 additions & 62 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Tool Call Image Preview
2+
3+
## Summary
4+
5+
Tool calls can return image output through structured MCP content, screenshot payloads, file reads, or explicit image URLs. The chat UI should keep existing params and response sections intact, then show a dedicated preview area below them when image output is available.
6+
7+
## Acceptance Criteria
8+
9+
- Existing params and response rendering stays in the same order and keeps current copy actions, diff rendering, terminal styling, and text output behavior.
10+
- Tool call blocks may persist `tool_call.imagePreviews`, where each item includes `id`, `data`, `mimeType`, optional `title`, and a source value.
11+
- Expanded tool blocks render image previews below the response section.
12+
- Collapsed tool blocks show a compact image count badge when previews exist.
13+
- Text-only tool output keeps the current UI.
14+
15+
## Data Flow
16+
17+
- Main process extracts image previews from tool output before response normalization.
18+
- MCP structured image items use source `mcp_image`.
19+
- `cdp_send` `Page.captureScreenshot` results use source `screenshot`.
20+
- Agent `read` image results keep vision analysis in `response` and attach the original image as `file_read`.
21+
- Image data is cached as `imgcache://` when an image cache function is available, with data URL and web URL fallbacks.
22+
23+
## Test Coverage
24+
25+
- Renderer tests cover collapsed image count, expanded preview placement, and image rendering.
26+
- Main dispatch tests cover ordinary tool results with structured image output.
27+
- Deferred tool execution tests cover image previews returned through `rawData`.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import type { MCPContentItem, ToolCallImagePreview } from '@shared/types/core/mcp'
2+
3+
type ImagePreviewInput = {
4+
data: string
5+
mimeType: string
6+
title?: string
7+
source: ToolCallImagePreview['source']
8+
}
9+
10+
type ExtractToolCallImagePreviewsParams = {
11+
toolName?: string
12+
toolArgs?: string
13+
content: string | MCPContentItem[]
14+
cacheImage?: (data: string) => Promise<string>
15+
}
16+
17+
const DATA_IMAGE_URL_PATTERN = /data:image\/[a-zA-Z0-9.+-]+;base64,[a-zA-Z0-9+/=\r\n]+/g
18+
const IMAGE_URL_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)(?:[?#].*)?$/i
19+
20+
function parseJsonRecord(value: unknown): Record<string, unknown> | null {
21+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
22+
return value as Record<string, unknown>
23+
}
24+
25+
if (typeof value !== 'string' || !value.trim()) {
26+
return null
27+
}
28+
29+
try {
30+
const parsed = JSON.parse(value)
31+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
32+
? (parsed as Record<string, unknown>)
33+
: null
34+
} catch {
35+
return null
36+
}
37+
}
38+
39+
function parseJsonValue(value: string): unknown {
40+
try {
41+
return JSON.parse(value)
42+
} catch {
43+
return null
44+
}
45+
}
46+
47+
function inferMimeType(data: string, fallback = 'image/png'): string {
48+
const dataUrlMatch = data.match(/^data:([^;]+);base64,/)
49+
if (dataUrlMatch?.[1]) {
50+
return dataUrlMatch[1]
51+
}
52+
53+
const normalized = data.toLowerCase().split(/[?#]/)[0]
54+
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'
55+
if (normalized.endsWith('.gif')) return 'image/gif'
56+
if (normalized.endsWith('.webp')) return 'image/webp'
57+
if (normalized.endsWith('.bmp')) return 'image/bmp'
58+
if (normalized.endsWith('.ico')) return 'image/x-icon'
59+
if (normalized.endsWith('.avif')) return 'image/avif'
60+
if (normalized.endsWith('.svg')) return 'image/svg+xml'
61+
return fallback
62+
}
63+
64+
function isImageReference(value: string): boolean {
65+
const trimmed = value.trim()
66+
if (!trimmed) return false
67+
if (trimmed.startsWith('data:image/')) return true
68+
if (trimmed.startsWith('imgcache://')) return true
69+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
70+
return IMAGE_URL_EXTENSION_PATTERN.test(trimmed)
71+
}
72+
return false
73+
}
74+
75+
function normalizeImagePayload(data: string, mimeType: string): string {
76+
const trimmed = data.trim()
77+
if (
78+
trimmed.startsWith('data:image/') ||
79+
trimmed.startsWith('imgcache://') ||
80+
trimmed.startsWith('http://') ||
81+
trimmed.startsWith('https://')
82+
) {
83+
return trimmed
84+
}
85+
86+
return `data:${mimeType || 'image/png'};base64,${trimmed}`
87+
}
88+
89+
async function cachePreviewData(
90+
data: string,
91+
cacheImage?: (data: string) => Promise<string>
92+
): Promise<string | undefined> {
93+
if (!cacheImage) {
94+
return undefined
95+
}
96+
97+
try {
98+
const cachedData = await cacheImage(data)
99+
const cachedDataTrimmed = cachedData.trim().toLowerCase()
100+
return cachedDataTrimmed.startsWith('data:image/') ? undefined : cachedData
101+
} catch {
102+
return undefined
103+
}
104+
}
105+
106+
function resolveScreenshotMimeType(format: unknown): string {
107+
if (typeof format !== 'string') {
108+
return 'image/png'
109+
}
110+
const normalized = format.trim().toLowerCase()
111+
if (normalized === 'jpeg' || normalized === 'jpg') return 'image/jpeg'
112+
if (normalized === 'webp') return 'image/webp'
113+
return 'image/png'
114+
}
115+
116+
function extractScreenshotPreview(
117+
toolName: string | undefined,
118+
toolArgs: string | undefined,
119+
content: string | MCPContentItem[]
120+
): ImagePreviewInput | null {
121+
if (toolName !== 'cdp_send' || typeof content !== 'string') {
122+
return null
123+
}
124+
125+
const parsedArgs = parseJsonRecord(toolArgs)
126+
if (!parsedArgs || parsedArgs.method !== 'Page.captureScreenshot') {
127+
return null
128+
}
129+
130+
const parsedContent = parseJsonRecord(content)
131+
const rawData = typeof parsedContent?.data === 'string' ? parsedContent.data.trim() : ''
132+
if (!rawData) {
133+
return null
134+
}
135+
136+
const screenshotParams = parseJsonRecord(parsedArgs.params)
137+
const mimeType = resolveScreenshotMimeType(screenshotParams?.format)
138+
139+
return {
140+
data: normalizeImagePayload(rawData, mimeType),
141+
mimeType,
142+
title: 'Page.captureScreenshot',
143+
source: 'screenshot'
144+
}
145+
}
146+
147+
function collectJsonImageReferences(value: unknown, output: ImagePreviewInput[]): void {
148+
if (typeof value === 'string') {
149+
const trimmed = value.trim()
150+
if (isImageReference(trimmed)) {
151+
output.push({
152+
data: trimmed,
153+
mimeType: inferMimeType(trimmed),
154+
source: 'tool_output'
155+
})
156+
}
157+
return
158+
}
159+
160+
if (Array.isArray(value)) {
161+
for (const item of value) {
162+
collectJsonImageReferences(item, output)
163+
}
164+
return
165+
}
166+
167+
if (typeof value === 'object' && value !== null) {
168+
for (const item of Object.values(value)) {
169+
collectJsonImageReferences(item, output)
170+
}
171+
}
172+
}
173+
174+
function extractStringImagePreviews(content: string): ImagePreviewInput[] {
175+
const previews: ImagePreviewInput[] = []
176+
const trimmed = content.trim()
177+
178+
if (isImageReference(trimmed)) {
179+
previews.push({
180+
data: trimmed,
181+
mimeType: inferMimeType(trimmed),
182+
source: 'tool_output'
183+
})
184+
}
185+
186+
const matches = trimmed.match(DATA_IMAGE_URL_PATTERN) ?? []
187+
for (const match of matches) {
188+
previews.push({
189+
data: match.replace(/\s+/g, ''),
190+
mimeType: inferMimeType(match),
191+
source: 'tool_output'
192+
})
193+
}
194+
195+
const parsed = parseJsonValue(trimmed)
196+
if (parsed !== null) {
197+
collectJsonImageReferences(parsed, previews)
198+
}
199+
200+
return previews
201+
}
202+
203+
function extractStructuredImagePreviews(content: MCPContentItem[]): ImagePreviewInput[] {
204+
return content
205+
.filter((item) => item.type === 'image')
206+
.map((item) => {
207+
const mimeType = item.mimeType || 'image/png'
208+
return {
209+
data: normalizeImagePayload(item.data, mimeType),
210+
mimeType,
211+
source: 'mcp_image' as const
212+
}
213+
})
214+
}
215+
216+
export async function extractToolCallImagePreviews(
217+
params: ExtractToolCallImagePreviewsParams
218+
): Promise<ToolCallImagePreview[]> {
219+
const inputs: ImagePreviewInput[] = []
220+
const screenshotPreview = extractScreenshotPreview(
221+
params.toolName,
222+
params.toolArgs,
223+
params.content
224+
)
225+
if (screenshotPreview) {
226+
inputs.push(screenshotPreview)
227+
}
228+
229+
if (Array.isArray(params.content)) {
230+
inputs.push(...extractStructuredImagePreviews(params.content))
231+
} else {
232+
inputs.push(...extractStringImagePreviews(params.content))
233+
}
234+
235+
const previews: ToolCallImagePreview[] = []
236+
const seen = new Set<string>()
237+
for (const input of inputs) {
238+
const data = await cachePreviewData(input.data, params.cacheImage)
239+
if (data && seen.has(data)) {
240+
continue
241+
}
242+
if (data) {
243+
seen.add(data)
244+
}
245+
previews.push({
246+
id: `${input.source}-${previews.length + 1}`,
247+
...(data ? { data } : {}),
248+
mimeType: data ? inferMimeType(data, input.mimeType) : input.mimeType,
249+
...(input.title ? { title: input.title } : {}),
250+
source: input.source
251+
})
252+
}
253+
254+
return previews
255+
}

src/main/presenter/agentRuntimePresenter/compactionService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export class CompactionService {
234234
extraReserveTokens?: number
235235
supportsVision: boolean
236236
preserveInterleavedReasoning: boolean
237+
preserveEmptyInterleavedReasoning?: boolean
237238
newUserContent: string | SendMessageInput
238239
signal?: AbortSignal
239240
}): Promise<CompactionIntent | null> {
@@ -269,6 +270,7 @@ export class CompactionService {
269270
extraReserveTokens?: number
270271
supportsVision: boolean
271272
preserveInterleavedReasoning: boolean
273+
preserveEmptyInterleavedReasoning?: boolean
272274
signal?: AbortSignal
273275
}): Promise<CompactionIntent | null> {
274276
throwIfAbortRequested(params.signal)
@@ -366,6 +368,7 @@ export class CompactionService {
366368
extraReserveTokens?: number
367369
supportsVision: boolean
368370
preserveInterleavedReasoning: boolean
371+
preserveEmptyInterleavedReasoning?: boolean
369372
records: ChatMessageRecord[]
370373
protectedTurnCount: number
371374
triggerThreshold: number
@@ -382,7 +385,8 @@ export class CompactionService {
382385
const turns = buildHistoryTurns(
383386
scopedRecords,
384387
params.supportsVision,
385-
params.preserveInterleavedReasoning
388+
params.preserveInterleavedReasoning,
389+
params.preserveEmptyInterleavedReasoning === true
386390
)
387391
if (turns.length === 0) {
388392
return null

0 commit comments

Comments
 (0)